use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
pub url: Option<String>,
pub token: Option<String>,
pub token_env: Option<String>,
pub token_command: Option<String>,
pub timeout: Option<u64>,
pub retries: Option<u32>,
pub ssl_verify: Option<bool>,
pub output: Option<String>,
}
impl Profile {
pub fn resolve_token(&self) -> Result<Option<String>, ConfigError> {
if let Some(cmd) = &self.token_command {
return resolve_token_command(cmd).map(Some);
}
if let Some(env_var) = &self.token_env {
return Ok(std::env::var(env_var).ok());
}
Ok(self.token.clone())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(flatten)]
pub profiles: HashMap<String, Profile>,
}
impl ConfigFile {
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
self.profiles.get(name)
}
pub fn profile_names(&self) -> Vec<&str> {
self.profiles.keys().map(String::as_str).collect()
}
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Parse(toml::de::Error),
TokenCommand { command: String, message: String },
MissingField(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io(err) => write!(f, "config file error: {}", err),
ConfigError::Parse(err) => write!(f, "config parse error: {}", err),
ConfigError::TokenCommand { command, message } => {
write!(f, "token command '{}' failed: {}", command, message)
}
ConfigError::MissingField(field) => {
write!(f, "missing required field: {}", field)
}
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(err: std::io::Error) -> Self {
ConfigError::Io(err)
}
}
impl From<toml::de::Error> for ConfigError {
fn from(err: toml::de::Error) -> Self {
ConfigError::Parse(err)
}
}
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("netbox-cli").join("config.toml"))
}
pub fn load_config() -> Result<Option<ConfigFile>, ConfigError> {
let Some(path) = config_path() else {
return Ok(None);
};
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path)?;
let config: ConfigFile = toml::from_str(&contents)?;
Ok(Some(config))
}
fn resolve_token_command(command: &str) -> Result<String, ConfigError> {
let output = if cfg!(target_os = "windows") {
Command::new("cmd").args(["/C", command]).output()
} else {
Command::new("sh").args(["-c", command]).output()
};
match output {
Ok(output) if output.status.success() => {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
Ok(output) => Err(ConfigError::TokenCommand {
command: command.to_string(),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}),
Err(err) => Err(ConfigError::TokenCommand {
command: command.to_string(),
message: err.to_string(),
}),
}
}
pub fn validate_profile(profile: &Profile) -> Result<(), ConfigError> {
if profile.url.is_none() {
return Err(ConfigError::MissingField("url".to_string()));
}
if profile.token.is_none() && profile.token_env.is_none() && profile.token_command.is_none() {
return Err(ConfigError::MissingField(
"token, token_env, or token_command".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_config_file() {
let toml = r#"
[default]
url = "https://netbox.example.com"
token_env = "NETBOX_TOKEN"
[prod]
url = "https://netbox.prod.example.com"
token_command = "pass show netbox/token"
timeout = 60
ssl_verify = true
output = "table"
"#;
let config: ConfigFile = toml::from_str(toml).unwrap();
assert_eq!(config.profiles.len(), 2);
let default = config.get_profile("default").unwrap();
assert_eq!(default.url.as_deref(), Some("https://netbox.example.com"));
assert_eq!(default.token_env.as_deref(), Some("NETBOX_TOKEN"));
let prod = config.get_profile("prod").unwrap();
assert_eq!(prod.timeout, Some(60));
assert_eq!(prod.ssl_verify, Some(true));
assert_eq!(prod.output.as_deref(), Some("table"));
}
#[test]
fn validate_profile_requires_url() {
let profile = Profile {
token: Some("tok".to_string()),
..Default::default()
};
let result = validate_profile(&profile);
assert!(matches!(result, Err(ConfigError::MissingField(_))));
}
#[test]
fn validate_profile_requires_token_source() {
let profile = Profile {
url: Some("https://example.com".to_string()),
..Default::default()
};
let result = validate_profile(&profile);
assert!(matches!(result, Err(ConfigError::MissingField(_))));
}
#[test]
fn validate_profile_accepts_token_env() {
let profile = Profile {
url: Some("https://example.com".to_string()),
token_env: Some("MY_TOKEN".to_string()),
..Default::default()
};
assert!(validate_profile(&profile).is_ok());
}
}