Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Clippy (push) Failing after 1m55s
CI / Web Blocking Checks (push) Successful in 47s
CI / Security Blocking Checks (push) Successful in 9s
CI / Web Advisory Checks (push) Successful in 30s
CI / Security Advisory Checks (push) Successful in 31s
CI / Tests (push) Failing after 8m6s
555 lines
17 KiB
Rust
555 lines
17 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::output::OutputFormat;
|
|
|
|
/// CLI configuration stored in user's home directory
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CliConfig {
|
|
/// Current active profile name
|
|
#[serde(
|
|
default = "default_profile_name",
|
|
rename = "profile",
|
|
alias = "current_profile"
|
|
)]
|
|
pub current_profile: String,
|
|
/// Named profiles (like SSH hosts)
|
|
#[serde(default)]
|
|
pub profiles: HashMap<String, Profile>,
|
|
/// Output format (table, json, yaml)
|
|
#[serde(
|
|
default = "default_format",
|
|
rename = "format",
|
|
alias = "default_output_format"
|
|
)]
|
|
pub format: String,
|
|
}
|
|
|
|
fn default_profile_name() -> String {
|
|
"default".to_string()
|
|
}
|
|
|
|
fn default_format() -> String {
|
|
"table".to_string()
|
|
}
|
|
|
|
/// A named profile for connecting to an Attune server
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Profile {
|
|
/// API endpoint URL
|
|
pub api_url: String,
|
|
/// Authentication token
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub auth_token: Option<String>,
|
|
/// Refresh token
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub refresh_token: Option<String>,
|
|
/// Output format override for this profile (deprecated — ignored, kept for deserialization compat)
|
|
#[serde(skip_serializing)]
|
|
#[allow(dead_code)]
|
|
pub output_format: Option<String>,
|
|
/// Optional description
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
impl Default for CliConfig {
|
|
fn default() -> Self {
|
|
let mut profiles = HashMap::new();
|
|
profiles.insert(
|
|
"default".to_string(),
|
|
Profile {
|
|
api_url: "http://localhost:8080".to_string(),
|
|
auth_token: None,
|
|
refresh_token: None,
|
|
output_format: None,
|
|
description: Some("Default local server".to_string()),
|
|
},
|
|
);
|
|
|
|
Self {
|
|
current_profile: "default".to_string(),
|
|
profiles,
|
|
format: default_format(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CliConfig {
|
|
/// Get the configuration file path
|
|
pub fn config_path() -> Result<PathBuf> {
|
|
// Respect XDG_CONFIG_HOME environment variable (for tests and user overrides)
|
|
let config_dir = if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
|
PathBuf::from(xdg_config)
|
|
} else {
|
|
dirs::config_dir().context("Failed to determine config directory")?
|
|
};
|
|
|
|
let attune_config_dir = config_dir.join("attune");
|
|
fs::create_dir_all(&attune_config_dir).context("Failed to create config directory")?;
|
|
|
|
Ok(attune_config_dir.join("config.yaml"))
|
|
}
|
|
|
|
/// Load configuration from file, or create default if not exists
|
|
pub fn load() -> Result<Self> {
|
|
let path = Self::config_path()?;
|
|
|
|
if !path.exists() {
|
|
let config = Self::default();
|
|
config.save()?;
|
|
return Ok(config);
|
|
}
|
|
|
|
let content = fs::read_to_string(&path).context("Failed to read config file")?;
|
|
|
|
let config: Self =
|
|
serde_yaml_ng::from_str(&content).context("Failed to parse config file")?;
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Save configuration to file
|
|
pub fn save(&self) -> Result<()> {
|
|
let path = Self::config_path()?;
|
|
|
|
let content = serde_yaml_ng::to_string(self).context("Failed to serialize config")?;
|
|
|
|
fs::write(&path, content).context("Failed to write config file")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the current active profile
|
|
pub fn current_profile(&self) -> Result<&Profile> {
|
|
self.profiles
|
|
.get(&self.current_profile)
|
|
.context(format!("Profile '{}' not found", self.current_profile))
|
|
}
|
|
|
|
/// Get a mutable reference to the current profile
|
|
pub fn current_profile_mut(&mut self) -> Result<&mut Profile> {
|
|
let profile_name = self.current_profile.clone();
|
|
self.profiles
|
|
.get_mut(&profile_name)
|
|
.context(format!("Profile '{}' not found", profile_name))
|
|
}
|
|
|
|
/// Get a profile by name
|
|
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
|
|
self.profiles.get(name)
|
|
}
|
|
|
|
/// Switch to a different profile
|
|
pub fn switch_profile(&mut self, name: String) -> Result<()> {
|
|
if !self.profiles.contains_key(&name) {
|
|
anyhow::bail!("Profile '{}' does not exist", name);
|
|
}
|
|
self.current_profile = name;
|
|
self.save()
|
|
}
|
|
|
|
/// Add or update a profile
|
|
pub fn set_profile(&mut self, name: String, profile: Profile) -> Result<()> {
|
|
self.profiles.insert(name, profile);
|
|
self.save()
|
|
}
|
|
|
|
/// Remove a profile
|
|
pub fn remove_profile(&mut self, name: &str) -> Result<()> {
|
|
if self.current_profile == name {
|
|
anyhow::bail!("Cannot remove active profile");
|
|
}
|
|
if name == "default" {
|
|
anyhow::bail!("Cannot remove the default profile");
|
|
}
|
|
self.profiles.remove(name);
|
|
self.save()
|
|
}
|
|
|
|
/// List all profile names
|
|
pub fn list_profiles(&self) -> Vec<String> {
|
|
let mut names: Vec<String> = self.profiles.keys().cloned().collect();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
/// Set the API URL for the current profile
|
|
///
|
|
/// Part of configuration management API - used by `attune config set api-url` command
|
|
#[allow(dead_code)]
|
|
pub fn set_api_url(&mut self, url: String) -> Result<()> {
|
|
let profile = self.current_profile_mut()?;
|
|
profile.api_url = url;
|
|
self.save()
|
|
}
|
|
|
|
/// Set authentication tokens for the current profile
|
|
pub fn set_auth(&mut self, access_token: String, refresh_token: String) -> Result<()> {
|
|
let profile = self.current_profile_mut()?;
|
|
profile.auth_token = Some(access_token);
|
|
profile.refresh_token = Some(refresh_token);
|
|
self.save()
|
|
}
|
|
|
|
/// Clear authentication tokens for the current profile
|
|
pub fn clear_auth(&mut self) -> Result<()> {
|
|
let profile = self.current_profile_mut()?;
|
|
profile.auth_token = None;
|
|
profile.refresh_token = None;
|
|
self.save()
|
|
}
|
|
|
|
/// Resolve the effective output format.
|
|
///
|
|
/// Priority (highest to lowest):
|
|
/// 1. Explicit CLI flag (`--json`, `--yaml`, `--output`)
|
|
/// 2. Config `format` field
|
|
///
|
|
/// The `cli_flag` parameter should be `None` when the user did not pass an
|
|
/// explicit flag (i.e. clap returned the default value `table` *without*
|
|
/// the user typing it). Callers should pass `Some(format)` only when the
|
|
/// user actually supplied the flag.
|
|
pub fn effective_format(&self, cli_override: Option<OutputFormat>) -> OutputFormat {
|
|
if let Some(fmt) = cli_override {
|
|
return fmt;
|
|
}
|
|
|
|
// Fall back to config value
|
|
match self.format.to_lowercase().as_str() {
|
|
"json" => OutputFormat::Json,
|
|
"yaml" => OutputFormat::Yaml,
|
|
_ => OutputFormat::Table,
|
|
}
|
|
}
|
|
|
|
/// Set a configuration value by key
|
|
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
|
|
match key {
|
|
"api_url" => {
|
|
let profile = self.current_profile_mut()?;
|
|
profile.api_url = value;
|
|
}
|
|
"format" | "output_format" | "default_output_format" => {
|
|
// Validate the value
|
|
match value.to_lowercase().as_str() {
|
|
"table" | "json" | "yaml" => {}
|
|
_ => anyhow::bail!(
|
|
"Invalid format '{}'. Must be one of: table, json, yaml",
|
|
value
|
|
),
|
|
}
|
|
self.format = value.to_lowercase();
|
|
}
|
|
"profile" | "current_profile" => {
|
|
self.switch_profile(value)?;
|
|
return Ok(());
|
|
}
|
|
_ => anyhow::bail!("Unknown config key: {}", key),
|
|
}
|
|
self.save()
|
|
}
|
|
|
|
/// Get a configuration value by key
|
|
pub fn get_value(&self, key: &str) -> Result<String> {
|
|
match key {
|
|
"api_url" => {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile.api_url.clone())
|
|
}
|
|
"format" | "output_format" | "default_output_format" => Ok(self.format.clone()),
|
|
"profile" | "current_profile" => Ok(self.current_profile.clone()),
|
|
"auth_token" => {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile
|
|
.auth_token
|
|
.as_ref()
|
|
.map(|_| "***")
|
|
.unwrap_or("(not set)")
|
|
.to_string())
|
|
}
|
|
"refresh_token" => {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile
|
|
.refresh_token
|
|
.as_ref()
|
|
.map(|_| "***")
|
|
.unwrap_or("(not set)")
|
|
.to_string())
|
|
}
|
|
_ => anyhow::bail!("Unknown config key: {}", key),
|
|
}
|
|
}
|
|
|
|
/// List all configuration keys and values for current profile
|
|
pub fn list_all(&self) -> Vec<(String, String)> {
|
|
let profile = match self.current_profile() {
|
|
Ok(p) => p,
|
|
Err(_) => return vec![],
|
|
};
|
|
|
|
vec![
|
|
("profile".to_string(), self.current_profile.clone()),
|
|
("format".to_string(), self.format.clone()),
|
|
("api_url".to_string(), profile.api_url.clone()),
|
|
(
|
|
"auth_token".to_string(),
|
|
profile
|
|
.auth_token
|
|
.as_ref()
|
|
.map(|_| "***")
|
|
.unwrap_or("(not set)")
|
|
.to_string(),
|
|
),
|
|
(
|
|
"refresh_token".to_string(),
|
|
profile
|
|
.refresh_token
|
|
.as_ref()
|
|
.map(|_| "***")
|
|
.unwrap_or("(not set)")
|
|
.to_string(),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Load configuration with optional profile override (without saving)
|
|
///
|
|
/// Used by `--profile` flag to temporarily use a different profile
|
|
pub fn load_with_profile(profile_name: Option<&str>) -> Result<Self> {
|
|
let mut config = Self::load()?;
|
|
|
|
if let Some(name) = profile_name {
|
|
// Temporarily switch profile without saving
|
|
if !config.profiles.contains_key(name) {
|
|
anyhow::bail!("Profile '{}' does not exist", name);
|
|
}
|
|
config.current_profile = name.to_string();
|
|
}
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Get the effective API URL (from override, current profile, or default)
|
|
pub fn effective_api_url(&self, override_url: &Option<String>) -> String {
|
|
if let Some(url) = override_url {
|
|
return url.clone();
|
|
}
|
|
|
|
if let Ok(profile) = self.current_profile() {
|
|
profile.api_url.clone()
|
|
} else {
|
|
"http://localhost:8080".to_string()
|
|
}
|
|
}
|
|
|
|
/// Get API URL for current profile (without override)
|
|
#[allow(unused)]
|
|
pub fn api_url(&self) -> Result<String> {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile.api_url.clone())
|
|
}
|
|
|
|
/// Get auth token for current profile
|
|
pub fn auth_token(&self) -> Result<Option<String>> {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile.auth_token.clone())
|
|
}
|
|
|
|
/// Get refresh token for current profile
|
|
pub fn refresh_token(&self) -> Result<Option<String>> {
|
|
let profile = self.current_profile()?;
|
|
Ok(profile.refresh_token.clone())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_config() {
|
|
let config = CliConfig::default();
|
|
assert_eq!(config.current_profile, "default");
|
|
assert_eq!(config.format, "table");
|
|
assert!(config.profiles.contains_key("default"));
|
|
|
|
let profile = config.current_profile().unwrap();
|
|
assert_eq!(profile.api_url, "http://localhost:8080");
|
|
assert!(profile.auth_token.is_none());
|
|
assert!(profile.refresh_token.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_api_url() {
|
|
let config = CliConfig::default();
|
|
|
|
// No override
|
|
assert_eq!(config.effective_api_url(&None), "http://localhost:8080");
|
|
|
|
// With override
|
|
let override_url = Some("http://example.com".to_string());
|
|
assert_eq!(
|
|
config.effective_api_url(&override_url),
|
|
"http://example.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_format_defaults_to_config() {
|
|
let config = CliConfig {
|
|
format: "json".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
// No CLI override → uses config
|
|
assert_eq!(config.effective_format(None), OutputFormat::Json);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_format_cli_overrides_config() {
|
|
let config = CliConfig {
|
|
format: "json".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
// CLI override wins
|
|
assert_eq!(
|
|
config.effective_format(Some(OutputFormat::Yaml)),
|
|
OutputFormat::Yaml
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_format_default_table() {
|
|
let config = CliConfig::default();
|
|
assert_eq!(config.effective_format(None), OutputFormat::Table);
|
|
}
|
|
|
|
#[test]
|
|
fn test_profile_management() {
|
|
let mut config = CliConfig::default();
|
|
|
|
// Add a new profile
|
|
let staging_profile = Profile {
|
|
api_url: "https://staging.example.com".to_string(),
|
|
auth_token: None,
|
|
refresh_token: None,
|
|
output_format: None,
|
|
description: Some("Staging environment".to_string()),
|
|
};
|
|
config
|
|
.set_profile("staging".to_string(), staging_profile)
|
|
.unwrap();
|
|
|
|
// List profiles
|
|
let profiles = config.list_profiles();
|
|
assert!(profiles.contains(&"default".to_string()));
|
|
assert!(profiles.contains(&"staging".to_string()));
|
|
|
|
// Switch to staging
|
|
config.switch_profile("staging".to_string()).unwrap();
|
|
assert_eq!(config.current_profile, "staging");
|
|
|
|
let profile = config.current_profile().unwrap();
|
|
assert_eq!(profile.api_url, "https://staging.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cannot_remove_default_profile() {
|
|
let mut config = CliConfig::default();
|
|
let result = config.remove_profile("default");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cannot_remove_active_profile() {
|
|
let mut config = CliConfig::default();
|
|
|
|
let test_profile = Profile {
|
|
api_url: "http://test.com".to_string(),
|
|
auth_token: None,
|
|
refresh_token: None,
|
|
output_format: None,
|
|
description: None,
|
|
};
|
|
config
|
|
.set_profile("test".to_string(), test_profile)
|
|
.unwrap();
|
|
config.switch_profile("test".to_string()).unwrap();
|
|
|
|
let result = config.remove_profile("test");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_set_value() {
|
|
let mut config = CliConfig::default();
|
|
|
|
assert_eq!(
|
|
config.get_value("api_url").unwrap(),
|
|
"http://localhost:8080"
|
|
);
|
|
assert_eq!(config.get_value("format").unwrap(), "table");
|
|
|
|
// Set API URL for current profile
|
|
config
|
|
.set_value("api_url", "http://test.com".to_string())
|
|
.unwrap();
|
|
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
|
|
|
|
// Set format
|
|
config.set_value("format", "json".to_string()).unwrap();
|
|
assert_eq!(config.get_value("format").unwrap(), "json");
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_value_validates_format() {
|
|
let mut config = CliConfig::default();
|
|
|
|
// Valid values
|
|
assert!(config.set_value("format", "table".to_string()).is_ok());
|
|
assert!(config.set_value("format", "json".to_string()).is_ok());
|
|
assert!(config.set_value("format", "yaml".to_string()).is_ok());
|
|
assert!(config.set_value("format", "JSON".to_string()).is_ok()); // case-insensitive
|
|
|
|
// Invalid value
|
|
assert!(config.set_value("format", "xml".to_string()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_compat_aliases() {
|
|
let mut config = CliConfig::default();
|
|
|
|
// Old key names should still work for get/set
|
|
assert!(config
|
|
.set_value("output_format", "json".to_string())
|
|
.is_ok());
|
|
assert_eq!(config.get_value("output_format").unwrap(), "json");
|
|
assert_eq!(config.get_value("format").unwrap(), "json");
|
|
|
|
assert!(config
|
|
.set_value("default_output_format", "yaml".to_string())
|
|
.is_ok());
|
|
assert_eq!(config.get_value("default_output_format").unwrap(), "yaml");
|
|
assert_eq!(config.get_value("format").unwrap(), "yaml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_deserialize_legacy_default_output_format() {
|
|
let yaml = r#"
|
|
profile: default
|
|
default_output_format: json
|
|
profiles:
|
|
default:
|
|
api_url: http://localhost:8080
|
|
"#;
|
|
let config: CliConfig = serde_yaml_ng::from_str(yaml).unwrap();
|
|
assert_eq!(config.format, "json");
|
|
}
|
|
}
|