difflore-cli 0.2.0

Your AI coding agent learned public code, not your team's private decisions. difflore turns past PR reviews into source-backed local rules.
use std::path::{Path, PathBuf};

use serde_json::Value;

pub(super) fn hook_chain_section(s: &mut String) {
    sw!(s, "\n## · Hook chain\n");
    sw!(s, "- platform: `{}`", std::env::consts::OS);
    sw!(
        s,
        "- forward mode: `{}`",
        crate::hook::forward::Mode::from_env()
    );

    let project_hash = crate::hook::forward::protocol::current_project_hash();
    sw!(s, "- current project hash: `{project_hash}`");
    match crate::hook::forward::protocol::endpoint_for_hash(&project_hash) {
        Ok(endpoint) => {
            sw!(s, "- endpoint: `{}`", endpoint.display());
            if cfg!(windows) {
                let pipe = endpoint
                    .file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or("(unknown)");
                sw!(s, "- windows named pipe basename: `{pipe}`");
            }
        }
        Err(e) => sw!(s, "- endpoint: unavailable ({e})"),
    }

    if cfg!(windows) {
        let self_warm = match std::env::var("DIFFLORE_WINDOWS_HOOK_SELF_WARM") {
            Ok(value)
                if matches!(
                    value.trim().to_ascii_lowercase().as_str(),
                    "0" | "false" | "off" | "never" | "no"
                ) =>
            {
                "disabled by DIFFLORE_WINDOWS_HOOK_SELF_WARM"
            }
            _ => "enabled; breakaway spawn is best-effort and falls back cold on failure",
        };
        sw!(
            s,
            "- windows strategy: direct GUI-subsystem `difflore-hook.exe`; MCP-hosted forwarders warm the current project plus a small set of known projects; cold miss falls back in-process"
        );
        sw!(s, "- windows self-warm: {self_warm}");
    } else {
        sw!(
            s,
            "- warm strategy: hook shim self-spawns a detached per-project forwarder on cold miss"
        );
    }
    sw!(
        s,
        "- forwarder scope: each endpoint serves one project hash; MCP may host multiple endpoints in one process"
    );

    let configs = hook_config_paths();
    let mut total = 0usize;
    for config in configs {
        total += append_hook_config_summary(s, &config);
    }
    if total == 0 {
        sw!(
            s,
            "- configured DiffLore hook commands: none found in known hook config files"
        );
    }
}

struct HookConfig {
    client: &'static str,
    path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct HookCommand {
    event: String,
    command: String,
    timeout: Option<i64>,
}

fn hook_config_paths() -> Vec<HookConfig> {
    let mut configs = Vec::new();
    if let Some(home) = home_dir() {
        configs.extend([
            HookConfig {
                client: "claude-code",
                path: home.join(".claude").join("settings.json"),
            },
            HookConfig {
                client: "codex",
                path: home.join(".codex").join("hooks.json"),
            },
            HookConfig {
                client: "gemini-cli",
                path: home.join(".gemini").join("settings.json"),
            },
            HookConfig {
                client: "windsurf",
                path: home.join(".codeium").join("windsurf").join("hooks.json"),
            },
        ]);
    }
    if let Ok(cwd) = std::env::current_dir() {
        configs.push(HookConfig {
            client: "cursor",
            path: cwd.join(".cursor").join("hooks.json"),
        });
    }
    configs
}

fn home_dir() -> Option<PathBuf> {
    difflore_core::infra::env::var_os(difflore_core::infra::env::DIFFLORE_MCP_HOME)
        .map(PathBuf::from)
        .or_else(dirs::home_dir)
}

fn append_hook_config_summary(s: &mut String, config: &HookConfig) -> usize {
    if !config.path.exists() {
        sw!(
            s,
            "- {} hooks: missing `{}`",
            config.client,
            config.path.display()
        );
        return 0;
    }

    let raw = match std::fs::read_to_string(&config.path) {
        Ok(raw) => raw,
        Err(e) => {
            sw!(
                s,
                "- {} hooks: unreadable `{}` ({e})",
                config.client,
                config.path.display()
            );
            return 0;
        }
    };
    let value = match serde_json::from_str::<Value>(&raw) {
        Ok(value) => value,
        Err(e) => {
            sw!(
                s,
                "- {} hooks: invalid JSON `{}` ({e})",
                config.client,
                config.path.display()
            );
            return 0;
        }
    };

    let commands = collect_difflore_hook_commands(&value);
    if commands.is_empty() {
        sw!(
            s,
            "- {} hooks: no DiffLore command in `{}`",
            config.client,
            config.path.display()
        );
        return 0;
    }

    sw!(
        s,
        "- {} hooks: {} DiffLore command(s) in `{}`",
        config.client,
        commands.len(),
        config.path.display()
    );
    for command in &commands {
        let exe = command_executable(&command.command);
        let exists = exe
            .as_deref()
            .filter(|path| path.is_absolute())
            .map(Path::exists);
        let exists_label = match exists {
            Some(true) => "exists",
            Some(false) => "missing",
            None => "not checked",
        };
        let timeout = command
            .timeout
            .map_or_else(|| "unset".to_owned(), |value| format!("{value}ms"));
        sw!(
            s,
            "  - event `{}`: kind=`{}`, binary=`{}`, timeout={}, file={}",
            command.event,
            classify_hook_command(&command.command),
            classify_hook_binary(exe.as_deref()),
            timeout,
            exists_label
        );
        sw!(s, "    command: `{}`", truncate_command(&command.command));
    }
    commands.len()
}

fn collect_difflore_hook_commands(value: &Value) -> Vec<HookCommand> {
    let mut out = Vec::new();
    if let Some(hooks) = value.get("hooks").and_then(Value::as_object) {
        for (event, node) in hooks {
            collect_commands_from_node(event, node, &mut out);
        }
    }
    out
}

fn collect_commands_from_node(event: &str, node: &Value, out: &mut Vec<HookCommand>) {
    match node {
        Value::Object(obj) => {
            if let Some(command) = obj.get("command").and_then(Value::as_str)
                && command.contains("difflore")
            {
                out.push(HookCommand {
                    event: event.to_owned(),
                    command: command.to_owned(),
                    timeout: obj.get("timeout").and_then(Value::as_i64),
                });
            }
            for value in obj.values() {
                collect_commands_from_node(event, value, out);
            }
        }
        Value::Array(items) => {
            for item in items {
                collect_commands_from_node(event, item, out);
            }
        }
        _ => {}
    }
}

fn classify_hook_command(command: &str) -> &'static str {
    if command.contains("difflore-launcher") && command.contains("--difflore-hook") {
        "legacy-launcher-hook"
    } else if command.contains("difflore-hook") {
        "direct-hook"
    } else if command.contains("difflore") {
        "difflore-unknown"
    } else {
        "other"
    }
}

fn classify_hook_binary(path: Option<&Path>) -> &'static str {
    let Some(path) = path else {
        return "unknown";
    };
    let normalized = path.to_string_lossy().replace('\\', "/");
    if normalized.contains("/target/debug/") {
        "debug"
    } else if normalized.contains("/target/release/") {
        "local-release"
    } else if normalized.contains("/.difflore/bin/") {
        "managed-stable"
    } else if normalized.contains("/.difflore/versions/") {
        "managed-version"
    } else {
        "unknown"
    }
}

fn command_executable(command: &str) -> Option<PathBuf> {
    let trimmed = command.trim();
    if trimmed.is_empty() {
        return None;
    }
    if let Some(rest) = trimmed.strip_prefix('"') {
        let end = rest.find('"')?;
        return Some(PathBuf::from(&rest[..end]));
    }
    let first = trimmed.split_whitespace().next()?;
    Some(PathBuf::from(first))
}

fn truncate_command(command: &str) -> String {
    const MAX: usize = 260;
    if command.chars().count() <= MAX {
        return command.to_owned();
    }
    let mut truncated = command.chars().take(MAX - 1).collect::<String>();
    truncated.push('');
    truncated
}

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

    #[test]
    fn collects_nested_and_flat_difflore_hook_commands() {
        let value = serde_json::json!({
            "hooks": {
                "PostToolUse": [{
                    "matcher": "Edit",
                    "hooks": [{
                        "command": "\"/tmp/difflore-hook\" --client claude-code",
                        "timeout": 5000
                    }]
                }],
                "post_run_command": [{
                    "command": "/tmp/difflore-hook --client windsurf",
                    "show_output": false
                }],
                "Other": [{"command": "/tmp/other"}]
            }
        });

        let commands = collect_difflore_hook_commands(&value);

        assert_eq!(commands.len(), 2);
        assert_eq!(commands[0].event, "PostToolUse");
        assert_eq!(commands[0].timeout, Some(5000));
        assert_eq!(commands[1].event, "post_run_command");
        assert_eq!(commands[1].timeout, None);
    }

    #[test]
    fn classifies_direct_legacy_and_binary_channels() {
        assert_eq!(
            classify_hook_command(
                r#""C:/Users/me/.difflore/bin/difflore-launcher.exe" --difflore-hook --client claude-code"#
            ),
            "legacy-launcher-hook"
        );
        assert_eq!(
            classify_hook_command(r#""/repo/target/debug/difflore-hook" --client codex"#),
            "direct-hook"
        );
        assert_eq!(
            classify_hook_binary(Some(Path::new("/repo/target/debug/difflore-hook"))),
            "debug"
        );
        assert_eq!(
            classify_hook_binary(Some(Path::new("/Users/me/.difflore/bin/difflore-hook"))),
            "managed-stable"
        );
    }
}