kagi-vault 0.1.1

Encrypted secrets and environment variable manager for teams — a secure, team-ready dotenv alternative with per-service isolation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

pub const KAGI_CONFIG_FILE: &str = "kagi.json";
pub const DEFAULT_ENV_NAME: &str = "development";
pub const STANDARD_ENV_NAMES: &[&str] = &["development", "test", "production"];

fn default_env_name() -> String {
    DEFAULT_ENV_NAME.to_string()
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct KagiConfig {
    pub version: String,
    pub project_id: String,
    pub services: HashMap<String, ServiceConfig>,
    #[serde(default)]
    pub settings: Settings,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ServiceConfig {
    pub file: String,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum NestedMode {
    Bool(bool),
    Paths(Vec<String>),
}

impl NestedMode {
    pub fn is_allowed(&self, relative_path: &str) -> bool {
        match self {
            NestedMode::Bool(v) => *v,
            NestedMode::Paths(paths) => {
                let rel = relative_path
                    .replace('\\', "/")
                    .trim_end_matches('/')
                    .to_string();
                paths.iter().any(|p| {
                    let p_norm = p.replace('\\', "/").trim_end_matches('/').to_string();
                    rel == p_norm || rel.starts_with(&(p_norm.clone() + "/"))
                })
            }
        }
    }
}

impl Default for NestedMode {
    fn default() -> Self {
        NestedMode::Bool(false)
    }
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
pub struct Settings {
    #[serde(default)]
    pub nested: NestedMode,
    #[serde(default)]
    pub envs: Vec<String>,
    #[serde(default = "default_env_name")]
    pub default_env: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sync: Option<serde_json::Value>,
}

impl KagiConfig {
    #[cfg(test)]
    pub fn new(version: impl Into<String>, project_id: impl Into<String>) -> Self {
        Self {
            version: version.into(),
            project_id: project_id.into(),
            services: HashMap::new(),
            settings: Settings {
                default_env: DEFAULT_ENV_NAME.to_string(),
                ..Settings::default()
            },
        }
    }

    pub fn new_with_settings(
        version: impl Into<String>,
        project_id: impl Into<String>,
        nested: NestedMode,
        envs: Vec<String>,
    ) -> Self {
        Self {
            version: version.into(),
            project_id: project_id.into(),
            services: HashMap::new(),
            settings: Settings {
                nested,
                envs,
                default_env: DEFAULT_ENV_NAME.to_string(),
                sync: None,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_default_nested() {
        let config = KagiConfig::new("2", "kgp_test");
        assert!(matches!(config.settings.nested, NestedMode::Bool(false)));
    }

    #[test]
    fn test_config_deserialize_missing_settings() {
        let json = r#"{"version":"2","project_id":"kgp_test","services":{}}"#;
        let config: KagiConfig = serde_json::from_str(json).unwrap();
        assert!(matches!(config.settings.nested, NestedMode::Bool(false)));
    }

    #[test]
    fn test_config_deserialize_explicit_false() {
        let json =
            r#"{"version":"2","project_id":"kgp_test","services":{},"settings":{"nested":false}}"#;
        let config: KagiConfig = serde_json::from_str(json).unwrap();
        assert!(matches!(config.settings.nested, NestedMode::Bool(false)));
    }

    #[test]
    fn test_config_deserialize_nested_paths() {
        let json = r#"{"version":"2","project_id":"kgp_test","services":{},"settings":{"nested":["api","web/frontend"]}}"#;
        let config: KagiConfig = serde_json::from_str(json).unwrap();
        assert!(matches!(config.settings.nested, NestedMode::Paths(_)));
        let paths = match &config.settings.nested {
            NestedMode::Paths(p) => p.clone(),
            _ => panic!("expected paths"),
        };
        assert_eq!(paths, vec!["api", "web/frontend"]);
    }

    #[test]
    fn test_nested_mode_is_allowed_bool_true() {
        let mode = NestedMode::Bool(true);
        assert!(mode.is_allowed("api/src"));
    }

    #[test]
    fn test_nested_mode_is_allowed_bool_false() {
        let mode = NestedMode::Bool(false);
        assert!(!mode.is_allowed("api/src"));
    }

    #[test]
    fn test_nested_mode_is_allowed_paths_exact() {
        let mode = NestedMode::Paths(vec!["api".into()]);
        assert!(mode.is_allowed("api"));
    }

    #[test]
    fn test_nested_mode_is_allowed_paths_prefix() {
        let mode = NestedMode::Paths(vec!["api".into(), "web/frontend".into()]);
        assert!(mode.is_allowed("api/src"));
        assert!(mode.is_allowed("web/frontend/routes"));
        assert!(!mode.is_allowed("admin"));
    }

    #[test]
    fn test_nested_mode_is_allowed_paths_with_slashes() {
        let mode = NestedMode::Paths(vec!["a/b/folder".into()]);
        assert!(mode.is_allowed("a/b/folder"));
        assert!(mode.is_allowed("a/b/folder/sub"));
        assert!(!mode.is_allowed("a/b/other"));
    }
}