Nazara Code Style Guide
General Guide
In regards to formatting, we follow the current Rust code style set by the Cargo formatter (cargo fmt).
The easiest way to enforce this, is using pre-commit to automatically format and check any code before it is committed.
This style is enforced by our CI as well. PRs will not be accepted unless the formatting issues are fixed.
Panics
We heavily discourage the use of the panic!() macro when aborting the process. Instead, we prefer to handle an error
and abort gracefully with a helpful error message and error code.
We only want to panic, when the issue we encounter is both catastrophic and unfixable by the user.
These are szenarios in which we don't want to panic:
- When input or config parameters are missing
- When connection to NetBox fails
- When an API request returns a different error code that Ok
While we want to panic in these cases:
- When filesystem operations fail
- When Nazara is not run as root
Instead of panicking, we write custom error types to wrap errors of certain functions and include a well formatted error message alongside the error.
For an exampel on how that looks, please check and of the error.rs files found in any of Nazara's packages.
Example:src/publisher/error.rs
Example:src/publisher/error.rs
In this example, you can see how we write our custom error types and are able to wrap errors that we receive from other functions in order to escalate them properly.
#![allow(unused)] fn main() { #[derive(Debug)] pub enum NazaraError { /// Something went wrong trying to parse DMI tables. Dmi(dmidecode::InvalidEntryPointError), /// Used to indicate that the collection of system data failed. UnableToCollectData(String), /// Used to indicate that one of the collected NWIs might be malformed or invalid. InvalidNetworkInterface(String), /// Used in case the NWI collector crate cannot find any interfaces. NoNetworkInterfaces(String), /// Rust couldn't convert a byte sequence to a UTF-8 string. UnableToParseUTF8(std::string::FromUtf8Error), InvalidPluginOutput(serde_json::Error), PluginExecution(String), /// Used for handling errors during file operations. FileOpError(std::io::Error), /// Indicates that no config file has been found, or it has been moved or deleted during program startup. NoConfigFileError(String), /// Indicates that a required config option is missing from the config file. MissingConfigOptionError(String), /// The Deserialization of a buffer to a type failed. DeserializationError(toml::de::Error), /// An error occured during the serialization of config parameters to a TOML value. SerializationError(toml::ser::Error), /// An error has occured while accessing data returned by NetBox. NetBoxApiError(String), /// Data returned by NetBox is incomplete. NetBoxMissingField(String, String), /// Wraps a `reqwest::Error`. Used for handling failures with requests and responses. Reqwest(reqwest::Error), /// NetBox returned a response with an unexpected code. UnexpectedResponse(reqwest::blocking::Response), /// Used to indicate the `thanix_client` version is incompatible with NetBox. VersionMismatch, /// Used to indicate that NetBox's initial response does not contain the application version. MissingVersion, /// Wraps a `serde_json::Error`. Used to handle failures with response serialization. JsonParse(serde_json::Error), NetlinkError(String), /// Expects a `String` message. Used for edge cases and general purpose error cases. Other(String), } pub type NazaraResult<T> = Result<T, NazaraError>; impl std::fmt::Display for NazaraError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NazaraError::Dmi(err) => { write!(f, "DMI Error: {err}") } NazaraError::UnableToCollectData(err) => { write!(f, "Collector Error: {err}") } NazaraError::InvalidNetworkInterface(err) => { write!(f, "Network Collector Error: {err}") } NazaraError::NoNetworkInterfaces(err) => { write!(f, "Network Collector Error: {err}") } NazaraError::UnableToParseUTF8(err) => { write!(f, "Unable to parse stdout from UTF8 to string: {err}") } NazaraError::InvalidPluginOutput(err) => { write!(f, "Plugin returned invalid JSON: {err}") } NazaraError::PluginExecution(err) => { write!(f, "Plugin execution failed: {err}") } NazaraError::FileOpError(err) => { write!(f, "File operation failed: {err}") } NazaraError::NoConfigFileError(err) => { write!(f, "No config file found: {err}") } NazaraError::MissingConfigOptionError(err) => { write!(f, "Missing required config parameter: {err}") } NazaraError::DeserializationError(err) => { write!(f, "Invalid config file: {err}") } NazaraError::SerializationError(err) => { write!(f, "Serialization error: {err}") } NazaraError::Reqwest(err) => { write!(f, "Failed to perform an HTTP request: {err}") } NazaraError::NetBoxApiError(err) => { write!(f, "NetBox API Error: {err}") } NazaraError::NetBoxMissingField(struc, field) => { write!( f, "NetBox returned incomplete data: Structure \"{struc}\" is missing field expected field \"{field}\"" ) } NazaraError::UnexpectedResponse(err) => { let status = err.status(); write!( f, "Got an unexpected HTTP response {} from NetBox: {:?}", status, err ) } NazaraError::NetlinkError(err) => { write!(f, "Netlink Error: {err}") } NazaraError::VersionMismatch => { write!( f, "Client version incompatible with NetBox version! Use client v1.x for NetBox v3.6.x and above, and v2.x for NetBox 4.x.", ) } NazaraError::MissingVersion => { write!( f, "NetBox version missing from response. Please check your installation.", ) } NazaraError::JsonParse(error) => { write!(f, "Error while parsing JSON: {error}") } NazaraError::Other(msg) => f.write_str(&msg), } } } impl From<std::io::Error> for NazaraError { fn from(value: std::io::Error) -> Self { Self::FileOpError(value) } } impl From<serde_json::Error> for NazaraError { fn from(value: serde_json::Error) -> Self { Self::JsonParse(value) } } impl From<std::string::FromUtf8Error> for NazaraError { fn from(value: std::string::FromUtf8Error) -> Self { Self::UnableToParseUTF8(value) } } impl From<reqwest::Error> for NazaraError { fn from(value: reqwest::Error) -> Self { Self::Reqwest(value) } } impl From<dmidecode::InvalidEntryPointError> for NazaraError { fn from(value: dmidecode::InvalidEntryPointError) -> Self { Self::Dmi(value) } } impl From<&str> for NazaraError { fn from(value: &str) -> Self { Self::Other(value.to_owned()) } } impl From<toml::de::Error> for NazaraError { fn from(value: toml::de::Error) -> Self { Self::DeserializationError(value) } } }
Focus on Readability
When writing code, we value readability above complexity.
Rust offers a lot of possibilities when it comes to reducing code down into a one-line statement. And that's nice, and sometimes even necessary.
However, we want to take a readability first approach with our codebase. Both to aid maintainability and increase accessibility for newcomers.
Of course, sometimes using Rust's more "advanced" features will drastically improve performance. So a balance has to be struck between readability and performance. It is very hard to define a solid policy for this, so this is a decision every developer and contributor has to do for themselves and - when necessary - engage in discussion with maintainers, devs and other contributors about their approach and possible alternatives.
Documenting Code
For a smilar reason, we encourage devs to properly document their contributions. This includes but is not limited to:
- Using inline comments to explain possibly hard to understand syntax
- Using Rust's powerful docstring feature to properly document functions, structs and modules
- Adding to this documentation when applicable
- Filling out Issue and PR templates appropriately to aid maintainers review their changes
The following examples will show you an ideal docstring style.
Example: Documenting Functions
Example: Documenting Functions
Documenting Functions:
#![allow(unused)] fn main() { /// This function does X. /// /// This function does X by doing Y using Z. (Detailed explanation optional) /// /// # Parameters /// * `arg: str` - A string argument to process /// /// # Returns /// * `Ok(str)` - Returns A, if ... /// * `Err` - Returns an `ErrType` pub fn foo(arg: str) -> Result<str, Err> { // ... } }
While not universally used by all projects, we use # Parameters and # Returns
sections, especially for larger functions.
If a function does not take arguments, the # Parameters section can be omitted.
However, if the function does not return (! type) - or returns (), this has to be indicated
in the # Returns section.
Other sections we use are:
# Aborts- If the function aborts the program. (E.g When input parameters are missing)# Panics- If the function can cause apanic!().
For both of these sections, list all - or at least the most common - reasons this behaviour can occur. This can help debugging immensely.
#![allow(unused)] fn main() { /// This function does X. /// /// # Paramters /// - `path: &str` - The path to a file /// /// # Returns /// - `String` - The contents of the file. /// /// # Aborts /// This function will exit the process if the file cannot be found. pub fn read_file(path: &str) -> String { match fs::read_to_string(path) { Ok(contents) => contents, Err(err) => { eprintln!("[Error] File '{}' does not exist: {}", path, err); process::exit(1); } } } }
Example: Documenting Structs
Example: Documenting Structs
Documenting Structs:
#![allow(unused)] fn main() { /// Information about a Person. pub struct Person { /// The name of the Person. pub name: String, /// The age of the Person. pub age: i64, } }
It is encouraged to briefly document every field of your struct, whether they are pub
or not does not matter.
Example: Documenting Modules
Example: Documenting Modules
Documenting Modules:
#![allow(unused)] fn main() { //! This module handles X. }
You can go into depth here about what a module does. This is encouraged for larger modules.
Please be aware, that a one-liner docstring above a function will not suffice. Maintainers may ask you to stick to the given format for your contribution.
We are aware that to some of you, this feels like cluttering the code files. However, we believe that properly documenting our code is the key to providing a more inclusive and more maintainable development experience for all.
Terminal Output/User Interface
When it is necessary to inform the user about a process, we want to make it short, but as expressive as possible. For this, the following styles apply:
Process X has been started...- Basic white text indicates the current process.\x1b[32m[success]\x1b[0m Something has succeeded- Green colored[success]prefix before the message.\x1b[31m[error]\x1b[0m Something has failed!- Red colored[error]prefix before error message. This should automatically be added by our custom error types when they are given a error message.\αΊ‹1b[36m[info]\x1b[0m Information level message.- Light blue colored[info]prefix.\x1b[33m[warning]\x1b[0m Something went wrong, but we can continue...- Yellow colored[warning]prefix.
To unify this coloring we have implemented several macros to be used for these status messages. These apply formatting and colors automatically and disable colors when the host's terminal does not support it.
The macros are called success!, warn!, failure!, info! you can find a usage example below.