longline 0.15.4

System-installed safety hook for Claude Code
Documentation
use std::path::Path;

use crate::config::discovery::{load_global_config, load_project_config};
use crate::config::overlays::{merge_overlay_config, merge_project_config, RuleSource};
use crate::config::rules::{RulesConfig, SafetyLevel, TrustLevel};

#[derive(Debug)]
pub struct FinalConfig {
    pub rules: RulesConfig,
    pub project_ai_prompt: Option<String>,
}

pub fn finalize_config(
    mut config: RulesConfig,
    home: &Path,
    project_dir: Option<&Path>,
    cli_trust_level: Option<TrustLevel>,
    cli_safety_level: Option<SafetyLevel>,
) -> Result<FinalConfig, String> {
    if let Some(global_config) = load_global_config(home)? {
        merge_overlay_config(&mut config, global_config, RuleSource::Global);
    }

    let mut project_ai_prompt: Option<String> = None;
    if let Some(dir) = project_dir {
        if let Some(project_config) = load_project_config(dir)? {
            project_ai_prompt = project_config
                .ai_judge
                .as_ref()
                .and_then(|a| a.prompt.as_ref())
                .filter(|prompt| !prompt.trim().is_empty())
                .cloned();
            merge_project_config(&mut config, project_config);
        }
    }

    if let Some(level) = cli_trust_level {
        config.trust_level = level;
    }
    if let Some(level) = cli_safety_level {
        config.safety_level = level;
    }

    Ok(FinalConfig {
        rules: config,
        project_ai_prompt,
    })
}

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

    use crate::config::{self, Allowlists};
    use crate::domain::Decision;

    use SafetyLevel::*;
    use TrustLevel::*;

    #[test]
    fn test_finalize_config_no_overlays() {
        let home = tempfile::TempDir::new().unwrap();
        let config = config::load_embedded_rules().unwrap();
        let original_trust = config.trust_level;
        let original_safety = config.safety_level;

        let result = finalize_config(config, home.path(), None, None, None).unwrap();

        assert_eq!(result.rules.trust_level, original_trust);
        assert_eq!(result.rules.safety_level, original_safety);
    }

    #[test]
    fn test_finalize_config_global_overrides_base() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "override_trust_level: full\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result = finalize_config(config, home.path(), None, None, None).unwrap();

        assert_eq!(result.rules.trust_level, Full);
    }

    #[test]
    fn test_finalize_config_project_overrides_global() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "override_trust_level: full\n",
        )
        .unwrap();

        let project_dir = tempfile::TempDir::new().unwrap();
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(project_dir.path())
            .output()
            .unwrap();
        let claude_dir = project_dir.path().join(".claude");
        std::fs::create_dir_all(&claude_dir).unwrap();
        std::fs::write(
            claude_dir.join("longline.yaml"),
            "override_trust_level: minimal\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result =
            finalize_config(config, home.path(), Some(project_dir.path()), None, None).unwrap();

        assert_eq!(
            result.rules.trust_level, Minimal,
            "Project config should override global config"
        );
    }

    #[test]
    fn test_finalize_config_cli_overrides_project_and_global() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "override_trust_level: full\n",
        )
        .unwrap();

        let project_dir = tempfile::TempDir::new().unwrap();
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(project_dir.path())
            .output()
            .unwrap();
        let claude_dir = project_dir.path().join(".claude");
        std::fs::create_dir_all(&claude_dir).unwrap();
        std::fs::write(
            claude_dir.join("longline.yaml"),
            "override_trust_level: full\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result = finalize_config(
            config,
            home.path(),
            Some(project_dir.path()),
            Some(Standard),
            None,
        )
        .unwrap();

        assert_eq!(
            result.rules.trust_level, Standard,
            "CLI --trust-level should override both global and project config"
        );
    }

    #[test]
    fn test_finalize_config_cli_safety_overrides_all() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "override_safety_level: strict\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result = finalize_config(config, home.path(), None, None, Some(Critical)).unwrap();

        assert_eq!(
            result.rules.safety_level, Critical,
            "CLI --safety-level should override global config"
        );
    }

    #[test]
    fn test_finalize_config_invalid_global_config_errors() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "not_a_valid_field: oops\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result = finalize_config(config, home.path(), None, None, None);
        assert!(result.is_err(), "Invalid global config should return error");
    }

    #[test]
    fn test_finalize_config_invalid_project_config_errors() {
        let home = tempfile::TempDir::new().unwrap();
        let project_dir = tempfile::TempDir::new().unwrap();
        std::fs::create_dir(project_dir.path().join(".git")).unwrap();
        std::fs::create_dir(project_dir.path().join(".claude")).unwrap();
        std::fs::write(
            project_dir.path().join(".claude").join("longline.yaml"),
            "not_a_valid_field: true\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let result = finalize_config(config, home.path(), Some(project_dir.path()), None, None);

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("unknown field"));
    }

    #[test]
    fn test_finalize_config_global_allowlist_not_duplicated() {
        let home = tempfile::TempDir::new().unwrap();
        let config_dir = home.path().join(".config").join("longline");
        std::fs::create_dir_all(&config_dir).unwrap();
        std::fs::write(
            config_dir.join("longline.yaml"),
            "allowlists:\n  commands:\n    - { command: my-custom-tool, trust: minimal }\n",
        )
        .unwrap();

        let config = config::load_embedded_rules().unwrap();
        let base_count = config.allowlists.commands.len();

        let result = finalize_config(config, home.path(), None, None, None).unwrap();

        assert_eq!(
            result.rules.allowlists.commands.len(),
            base_count + 1,
            "Global config allowlist should be merged exactly once"
        );
    }

    #[test]
    fn test_finalize_config_extracts_project_ai_prompt() {
        let tmp = tempfile::tempdir().unwrap();
        let project_dir = tempfile::tempdir().unwrap();
        let repo = project_dir.path();
        std::fs::create_dir(repo.join(".git")).unwrap();
        std::fs::create_dir(repo.join(".claude")).unwrap();
        std::fs::write(
            repo.join(".claude").join("longline.yaml"),
            "ai_judge:\n  prompt: |\n    {language} {code} {cwd}\n",
        )
        .unwrap();
        let base = RulesConfig {
            version: 1,
            default_decision: Decision::Ask,
            safety_level: High,
            trust_level: Standard,
            allowlists: Allowlists {
                commands: vec![],
                paths: vec![],
            },
            rules: vec![],
        };
        let result = finalize_config(base, tmp.path(), Some(repo), None, None).unwrap();
        let prompt = result.project_ai_prompt.expect("prompt should be Some");
        assert!(prompt.contains("{code}"), "got: {prompt}");
    }

    #[test]
    fn test_finalize_config_no_project_ai_prompt_when_absent() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let repo = tmp.path();
        std::fs::create_dir(repo.join(".git")).unwrap();

        let base = RulesConfig {
            version: 1,
            default_decision: Decision::Ask,
            safety_level: High,
            trust_level: Standard,
            allowlists: Allowlists {
                commands: vec![],
                paths: vec![],
            },
            rules: vec![],
        };
        let result = finalize_config(base, tmp.path(), Some(repo), None, None)
            .expect("finalize_config should succeed");
        assert!(result.project_ai_prompt.is_none());
    }

    #[test]
    fn test_finalize_config_empty_project_ai_prompt_is_none() {
        use std::fs;

        let tmp = tempfile::tempdir().expect("tempdir");
        let repo = tmp.path();
        fs::create_dir(repo.join(".claude")).unwrap();
        fs::write(
            repo.join(".claude").join("longline.yaml"),
            "ai_judge:\n  prompt: \"   \"\n",
        )
        .unwrap();
        fs::create_dir(repo.join(".git")).unwrap();

        let base = RulesConfig {
            version: 1,
            default_decision: Decision::Ask,
            safety_level: High,
            trust_level: Standard,
            allowlists: Allowlists {
                commands: vec![],
                paths: vec![],
            },
            rules: vec![],
        };
        let result = finalize_config(base, tmp.path(), Some(repo), None, None)
            .expect("finalize_config should succeed");
        assert!(
            result.project_ai_prompt.is_none(),
            "all-whitespace prompt must be filtered to None"
        );
    }
}