kagi-vault 0.1.2

Encrypted secrets and environment variable manager for teams — a secure, team-ready dotenv alternative with per-service isolation
use crate::domain::config::{
    DEFAULT_ENV_NAME, KAGI_CONFIG_FILE, KagiConfig, NestedMode, STANDARD_ENV_NAMES,
};
use crate::domain::error::DomainError;
use crate::infrastructure::key_manager::KeyManager;
use std::fs;
use std::path::{Path, PathBuf};

pub struct InitService {
    key_manager: KeyManager,
    base_path: PathBuf,
    nested: bool,
    envs: Vec<String>,
}

impl InitService {
    #[cfg(test)]
    pub fn new(base_path: PathBuf) -> Self {
        Self::with_nested(base_path, false)
    }

    #[cfg(test)]
    pub fn with_nested(base_path: PathBuf, nested: bool) -> Self {
        Self::with_nested_and_envs(base_path, nested, None)
    }

    pub fn with_nested_and_envs(
        base_path: PathBuf,
        nested: bool,
        envs: Option<Vec<String>>,
    ) -> Self {
        let envs = match envs {
            Some(envs) => {
                let envs: Vec<String> = envs
                    .into_iter()
                    .filter(|env| !env.trim().is_empty())
                    .collect();
                if envs.is_empty() {
                    STANDARD_ENV_NAMES
                        .iter()
                        .map(|env| (*env).to_string())
                        .collect()
                } else {
                    envs
                }
            }
            None => vec![DEFAULT_ENV_NAME.to_string()],
        };
        let envs = if envs.iter().any(|env| env == DEFAULT_ENV_NAME) {
            envs
        } else {
            let mut with_default = vec![DEFAULT_ENV_NAME.to_string()];
            with_default.extend(envs);
            with_default
        };

        Self {
            key_manager: KeyManager::new(base_path.clone()),
            base_path,
            nested,
            envs,
        }
    }

    pub fn execute(&self) -> Result<(), DomainError> {
        fs::create_dir_all(&self.base_path)?;
        set_private_dir_permissions(&self.base_path)?;
        fs::create_dir_all(self.base_path.join("secrets"))?;
        set_private_dir_permissions(&self.base_path.join("secrets"))?;

        let project_id = KeyManager::generate_project_id();
        let member_id = KeyManager::generate_member_id();
        let config = KagiConfig::new_with_settings(
            "2",
            project_id.clone(),
            NestedMode::Bool(self.nested),
            self.envs.clone(),
        );
        let config_path = self.base_path.join(KAGI_CONFIG_FILE);
        fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
        set_private_file_permissions(&config_path)?;
        self.key_manager
            .initialize_project(&project_id, &member_id)?;

        if let Some(parent) = self.base_path.parent()
            && let Some(git_root) = find_git_root(parent)
        {
            let gitignore_path = git_root.join(".gitignore");
            let kagi_prefix = gitignore_kagi_prefix(&git_root, parent);
            if gitignore_path.exists() {
                let content = fs::read_to_string(&gitignore_path)?;
                let content = next_gitignore_content(&content, &kagi_prefix);
                fs::write(&gitignore_path, content)?;
            } else {
                fs::write(&gitignore_path, gitignore_entries())?;
            }
        }

        Ok(())
    }
}

fn find_git_root(start: &Path) -> Option<PathBuf> {
    let mut current = start;
    loop {
        if current.join(".git").exists() {
            return Some(current.to_path_buf());
        }
        current = current.parent()?;
    }
}

fn gitignore_kagi_prefix(git_root: &Path, project_root: &Path) -> String {
    let relative = project_root.strip_prefix(git_root).unwrap_or(project_root);
    if relative.as_os_str().is_empty() {
        ".kagi".to_string()
    } else {
        format!("{}/.kagi", path_to_gitignore_pattern(relative))
    }
}

fn path_to_gitignore_pattern(path: &Path) -> String {
    path.components()
        .map(|component| component.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}

fn gitignore_entries() -> String {
    [".env", ".env.*", "!.env.example"].join("\n") + "\n"
}

fn next_gitignore_content(content: &str, kagi_prefix: &str) -> String {
    let mut lines: Vec<String> = content
        .lines()
        .filter(|line| !is_broad_kagi_ignore(line.trim(), kagi_prefix))
        .map(ToString::to_string)
        .collect();

    for entry in gitignore_entries().lines() {
        if !lines.iter().any(|line| line.trim() == entry) {
            lines.push(entry.to_string());
        }
    }

    let mut next = lines.join("\n");
    next.push('\n');
    next
}

fn is_broad_kagi_ignore(pattern: &str, kagi_prefix: &str) -> bool {
    if pattern.is_empty() || pattern.starts_with('#') || pattern.starts_with('!') {
        return false;
    }

    let normalized = pattern.trim_start_matches('/');
    let root_wide_patterns = [".kagi", ".kagi/", ".kagi/*", ".kagi/**"];
    if root_wide_patterns.contains(&normalized) {
        return true;
    }

    [
        kagi_prefix.to_string(),
        format!("{kagi_prefix}/"),
        format!("{kagi_prefix}/*"),
        format!("{kagi_prefix}/**"),
    ]
    .iter()
    .any(|entry| normalized == entry)
}

fn set_private_dir_permissions(_path: &Path) -> Result<(), DomainError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(_path, fs::Permissions::from_mode(0o700))?;
    }
    Ok(())
}

fn set_private_file_permissions(_path: &Path) -> Result<(), DomainError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(_path, fs::Permissions::from_mode(0o600))?;
    }
    Ok(())
}

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

    #[test]
    fn test_init_creates_structure() {
        let dir = TempDir::new().unwrap();
        let base = dir.path().join(".kagi");
        let service = InitService::new(base.clone());
        service.execute().unwrap();
        assert!(base.join(KAGI_CONFIG_FILE).exists());
        assert!(base.join("access.json").exists());
        assert!(base.join("secrets").exists());
        assert!(!base.join("key").exists());
        assert!(!base.join("members").exists());
        assert!(!base.join("access").exists());
        assert!(!base.join("services").exists());
        assert!(!base.join("envs").exists());

        let config: KagiConfig =
            serde_json::from_str(&fs::read_to_string(base.join(KAGI_CONFIG_FILE)).unwrap())
                .unwrap();
        assert!(config.project_id.starts_with("kgp_"));
        assert!(matches!(
            config.settings.nested,
            crate::domain::config::NestedMode::Bool(false)
        ));
        let access: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(base.join("access.json")).unwrap()).unwrap();
        assert_eq!(access["members"].as_array().unwrap().len(), 1);
        assert_eq!(access["members"][0]["status"], "active");

        let gitignore = dir.path().join(".gitignore");
        assert!(!gitignore.exists());
    }

    #[test]
    fn test_init_can_enable_nested() {
        let dir = TempDir::new().unwrap();
        let base = dir.path().join(".kagi");
        let service = InitService::with_nested(base.clone(), true);
        service.execute().unwrap();

        let config: KagiConfig =
            serde_json::from_str(&fs::read_to_string(base.join(KAGI_CONFIG_FILE)).unwrap())
                .unwrap();
        assert!(matches!(
            config.settings.nested,
            crate::domain::config::NestedMode::Bool(true)
        ));
    }

    #[test]
    fn test_init_updates_gitignore_in_git_repo() {
        let dir = TempDir::new().unwrap();
        fs::create_dir(dir.path().join(".git")).unwrap();

        let base = dir.path().join(".kagi");
        let service = InitService::new(base);
        service.execute().unwrap();

        let gitignore = dir.path().join(".gitignore");
        assert!(gitignore.exists());
        let content = fs::read_to_string(gitignore).unwrap();
        assert!(content.contains(".env"));
        assert!(content.contains(".env.*"));
        assert!(!content.contains(".kagi/local/"));
        assert!(!content.contains(".kagi/*.key"));
        assert!(
            !content
                .lines()
                .any(|line| is_broad_kagi_ignore(line.trim(), ".kagi"))
        );
    }

    #[test]
    fn test_init_appends_to_existing_gitignore() {
        let dir = TempDir::new().unwrap();
        fs::create_dir(dir.path().join(".git")).unwrap();
        let gitignore = dir.path().join(".gitignore");
        fs::write(&gitignore, "/target\n").unwrap();

        let base = dir.path().join(".kagi");
        let service = InitService::new(base);
        service.execute().unwrap();

        let content = fs::read_to_string(&gitignore).unwrap();
        assert!(content.contains("/target"));
        assert!(content.contains(".env"));
        assert!(!content.contains(".kagi/local/"));
        assert!(!content.contains(".kagi/*.key"));
        assert!(
            !content
                .lines()
                .any(|line| is_broad_kagi_ignore(line.trim(), ".kagi"))
        );
        assert!(content.ends_with("\n"));
    }

    #[test]
    fn test_init_rewrites_gitignore_for_subdirectory_project() {
        let dir = TempDir::new().unwrap();
        fs::create_dir(dir.path().join(".git")).unwrap();
        fs::create_dir_all(dir.path().join("tests")).unwrap();
        let gitignore = dir.path().join(".gitignore");
        fs::write(&gitignore, ".kagi/\n/tests/.kagi/\n/target\n").unwrap();

        let base = dir.path().join("tests/.kagi");
        let service = InitService::new(base);
        service.execute().unwrap();

        let content = fs::read_to_string(&gitignore).unwrap();
        assert!(content.contains("/target"));
        assert!(content.contains(".env"));
        assert!(!content.contains("tests/.kagi/local/"));
        assert!(!content.contains("tests/.kagi/*.key"));
        assert!(
            !content
                .lines()
                .any(|line| is_broad_kagi_ignore(line.trim(), "tests/.kagi"))
        );
    }
}