svccat 0.22.0

Detect drift between your declared service catalog and what actually lives in the repo.
Documentation
use crate::{discovery, drift, manifest};
use anyhow::{anyhow, Result};
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct HookConfig {
    pub fail_on_drift: bool,
    pub check_uncommitted: bool,
    pub manifest_path: PathBuf,
    pub root: PathBuf,
    pub ignore: Vec<String>,
    pub depth: u32,
}

impl Default for HookConfig {
    fn default() -> Self {
        HookConfig {
            fail_on_drift: true,
            check_uncommitted: true,
            manifest_path: PathBuf::from("services.yaml"),
            root: PathBuf::from("."),
            ignore: vec![],
            depth: 3,
        }
    }
}

#[derive(Debug, Clone)]
pub struct HookCheckResult {
    pub passed: bool,
    pub error_count: usize,
    pub warning_count: usize,
    pub messages: Vec<String>,
    pub block_commit: bool,
}

impl HookCheckResult {
    pub fn success() -> Self {
        HookCheckResult {
            passed: true,
            error_count: 0,
            warning_count: 0,
            messages: vec!["✓ Manifest is in sync - ready to commit".to_string()],
            block_commit: false,
        }
    }

    pub fn failure(error_count: usize, warning_count: usize, messages: Vec<String>) -> Self {
        HookCheckResult {
            passed: false,
            error_count,
            warning_count,
            messages,
            block_commit: true,
        }
    }
}

pub fn run_pre_commit_check(config: &HookConfig) -> Result<HookCheckResult> {
    if !config.manifest_path.exists() {
        return Ok(HookCheckResult::failure(
            1,
            0,
            vec![
                format!(
                    "✗ Manifest file not found: {}",
                    config.manifest_path.display()
                ),
                "Create services.yaml with service declarations before committing".to_string(),
            ],
        ));
    }

    let manifest = manifest::Manifest::load(&config.manifest_path)?;
    let discovered = discovery::discover_services_with_opts(
        &config.root,
        &manifest,
        &config.ignore,
        config.depth,
    );
    let report = drift::analyze(&manifest, &discovered, &config.root);

    let error_count = report.error_count();

    if error_count == 0 {
        Ok(HookCheckResult::success())
    } else {
        let mut messages = vec![format!("{} drift error(s) detected", error_count)];

        for drift_item in &report.drifts {
            if drift_item.severity == crate::drift::Severity::Error {
                messages.push(format!(
                    "  - {}: {}",
                    drift_item.service, drift_item.message
                ));
            }
        }

        messages.push("\nRun 'svccat check' to review and fix drift before committing".to_string());

        if config.fail_on_drift {
            messages.push("\nTo bypass this check: git commit --no-verify".to_string());
        }

        Ok(HookCheckResult::failure(error_count, 0, messages))
    }
}

pub fn install(repo_root: &Path, hook_type: &str, _fail_on_drift: bool) -> Result<()> {
    let git_dir = repo_root.join(".git");
    if !git_dir.exists() {
        return Err(anyhow!(
            "Not a git repository: {} not found",
            git_dir.display()
        ));
    }

    let hooks_dir = git_dir.join("hooks");
    fs::create_dir_all(&hooks_dir)?;

    match hook_type {
        "pre-commit" => {
            let path = hooks_dir.join("pre-commit");
            let script = "#!/bin/sh\nsvccat check > /dev/null 2>&1 || exit 1";
            fs::write(&path, script)?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
            }
            println!("{}  Pre-commit hook installed", "".green());
        }
        "pre-push" => {
            let path = hooks_dir.join("pre-push");
            let script = "#!/bin/sh\nsvccat check > /dev/null 2>&1 || exit 1";
            fs::write(&path, script)?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
            }
            println!("{}  Pre-push hook installed", "".green());
        }
        _ => return Err(anyhow!("Unknown hook type: {}", hook_type)),
    }

    Ok(())
}

pub fn install_pre_commit_hook(repo_root: &Path) -> Result<()> {
    install(repo_root, "pre-commit", true)
}

pub fn uninstall_pre_commit_hook(repo_root: &Path) -> Result<()> {
    let pre_commit_path = repo_root.join(".git/hooks/pre-commit");
    if !pre_commit_path.exists() {
        return Err(anyhow!("Pre-commit hook not found"));
    }
    fs::remove_file(&pre_commit_path)?;
    println!("{}  Pre-commit hook removed", "".green());
    Ok(())
}

pub fn is_hook_installed(repo_root: &Path) -> bool {
    repo_root.join(".git/hooks/pre-commit").exists()
}

#[derive(Debug, Clone)]
pub struct GitStatus {
    pub staged_changes: Vec<String>,
    pub unstaged_changes: Vec<String>,
}

impl GitStatus {
    pub fn manifest_changed(&self, manifest_path: &Path) -> bool {
        let filename = manifest_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("services.yaml");
        self.staged_changes.iter().any(|f| f.ends_with(filename))
            || self.unstaged_changes.iter().any(|f| f.ends_with(filename))
    }

    pub fn has_changes(&self) -> bool {
        !self.staged_changes.is_empty() || !self.unstaged_changes.is_empty()
    }
}

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

    #[test]
    fn test_hook_config_default() {
        let config = HookConfig::default();
        assert!(config.fail_on_drift);
        assert_eq!(config.depth, 3);
    }

    #[test]
    fn test_hook_check_result_success() {
        let result = HookCheckResult::success();
        assert!(result.passed);
        assert_eq!(result.error_count, 0);
    }

    #[test]
    fn test_hook_check_result_failure() {
        let result = HookCheckResult::failure(2, 1, vec!["Error".to_string()]);
        assert!(!result.passed);
        assert!(result.block_commit);
        assert_eq!(result.error_count, 2);
    }

    #[test]
    fn test_git_status_manifest_changed() {
        let status = GitStatus {
            staged_changes: vec!["services.yaml".to_string()],
            unstaged_changes: vec![],
        };
        assert!(status.manifest_changed(Path::new("services.yaml")));
    }

    #[test]
    fn test_git_status_has_changes() {
        let status = GitStatus {
            staged_changes: vec!["file.txt".to_string()],
            unstaged_changes: vec![],
        };
        assert!(status.has_changes());
    }
}