ccd-cli 1.0.0-alpha.9

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::ExitCode;

use anyhow::Result;
use serde::Serialize;

use crate::output::CommandReport;
use crate::paths::write;

const CCD_MARKER: &str = "# CCD-MANAGED";
const HOOK_NAME: &str = "pre-commit";

fn hook_script() -> String {
    format!(
        r#"#!/bin/sh
{CCD_MARKER}
# Installed by: ccd hooks install
# Gates: ccd check + ccd doctor + ccd sync --check

set -e

# Resolve ccd binary (same search order as ccd-start).
if [ -n "$CCD_BIN" ] && [ -x "$CCD_BIN" ]; then
    CCD="$CCD_BIN"
elif command -v ccd >/dev/null 2>&1; then
    CCD=ccd
elif [ -x "$HOME/.ccd/bin/ccd" ]; then
    CCD="$HOME/.ccd/bin/ccd"
elif [ -x "$HOME/.cargo/bin/ccd" ]; then
    CCD="$HOME/.cargo/bin/ccd"
else
    echo "ccd: not found — skipping pre-commit gates"
    exit 0
fi

CHECK_OUTPUT="$(mktemp "${{TMPDIR:-/tmp}}/ccd-check.XXXXXX")"
if ! "$CCD" check --path . >"$CHECK_OUTPUT" 2>&1; then
    cat "$CHECK_OUTPUT"
    rm -f "$CHECK_OUTPUT"
    exit 1
fi
rm -f "$CHECK_OUTPUT"

"$CCD" doctor --path . --skip-repo-native-checks
"$CCD" sync --path . --check
"#
    )
}

// ── install ────────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct InstallReport {
    command: &'static str,
    ok: bool,
    action: &'static str,
    hook: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
}

impl CommandReport for InstallReport {
    fn exit_code(&self) -> ExitCode {
        if self.ok {
            ExitCode::SUCCESS
        } else {
            ExitCode::from(1)
        }
    }

    fn render_text(&self) {
        println!("{}", self.message);
        if let Some(note) = &self.note {
            println!("Note: {note}");
        }
    }
}

pub fn install(repo_root: &Path, force: bool) -> Result<InstallReport> {
    let git_dir = repo_root.join(".git");
    if !git_dir.is_dir() {
        return Ok(InstallReport {
            command: "hooks install",
            ok: false,
            action: "error",
            hook: HOOK_NAME.to_owned(),
            message: "Not a git repository — cannot install hooks.".to_owned(),
            note: None,
        });
    }

    let ci_note = detect_github_ci(repo_root);

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

    if hook_path.exists() {
        let existing = fs::read_to_string(&hook_path)?;
        if existing.contains(CCD_MARKER) {
            // Already CCD-managed — overwrite (idempotent update).
            write_hook(&hook_path, true)?;
            return Ok(InstallReport {
                command: "hooks install",
                ok: true,
                action: "updated",
                hook: HOOK_NAME.to_owned(),
                message: format!("Updated CCD {HOOK_NAME} hook."),
                note: ci_note,
            });
        }

        if !force {
            return Ok(InstallReport {
                command: "hooks install",
                ok: false,
                action: "refused",
                hook: HOOK_NAME.to_owned(),
                message: format!(
                    "Existing {HOOK_NAME} hook is not CCD-managed — \
                     refusing to overwrite. Use --force to replace it."
                ),
                note: None,
            });
        }

        write_hook(&hook_path, true)?;
        return Ok(InstallReport {
            command: "hooks install",
            ok: true,
            action: "installed",
            hook: HOOK_NAME.to_owned(),
            message: format!("Installed CCD {HOOK_NAME} hook."),
            note: ci_note,
        });
    }

    write_hook(&hook_path, false)?;
    Ok(InstallReport {
        command: "hooks install",
        ok: true,
        action: "installed",
        hook: HOOK_NAME.to_owned(),
        message: format!("Installed CCD {HOOK_NAME} hook."),
        note: ci_note,
    })
}

/// Returns a note if the repo appears to use GitHub CI, suggesting that
/// CI-based enforcement may be a better fit than local git hooks.
fn detect_github_ci(repo_root: &Path) -> Option<String> {
    let has_workflows = repo_root.join(".github/workflows").is_dir();
    if has_workflows {
        return Some(
            "This repo has GitHub Actions workflows. \
             Consider CI-based enforcement (ccd doctor/sync in a workflow) \
             as a stronger alternative to local hooks."
                .to_owned(),
        );
    }
    None
}

fn write_hook(path: &Path, replace: bool) -> Result<()> {
    let result = if replace {
        write::replace_text(path, &hook_script(), Some(0o755))
    } else {
        write::create_text(path, &hook_script(), Some(0o755))
    };
    result?;
    Ok(())
}

// ── check ──────────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct CheckReport {
    command: &'static str,
    ok: bool,
    checks: Vec<HookCheck>,
}

#[derive(Serialize)]
struct HookCheck {
    hook: String,
    status: &'static str,
    severity: &'static str,
    message: String,
}

impl CommandReport for CheckReport {
    fn exit_code(&self) -> ExitCode {
        let has_error = self.checks.iter().any(|c| c.severity == "error");
        if has_error {
            ExitCode::from(1)
        } else {
            ExitCode::SUCCESS
        }
    }

    fn render_text(&self) {
        for c in &self.checks {
            let label = match c.status {
                "pass" => "PASS",
                "warn" => "WARN",
                "fail" => "FAIL",
                _ => "INFO",
            };
            println!("[{label}] {}", c.message);
        }
    }
}

pub fn check(repo_root: &Path) -> Result<CheckReport> {
    let mut checks = Vec::new();

    let git_dir = repo_root.join(".git");
    if !git_dir.is_dir() {
        checks.push(HookCheck {
            hook: HOOK_NAME.to_owned(),
            status: "fail",
            severity: "error",
            message: "Not a git repository.".to_owned(),
        });
        return Ok(CheckReport {
            command: "hooks check",
            ok: false,
            checks,
        });
    }

    let hook_path = git_dir.join("hooks").join(HOOK_NAME);

    if !hook_path.exists() {
        checks.push(HookCheck {
            hook: HOOK_NAME.to_owned(),
            status: "warn",
            severity: "warning",
            message: format!(
                "{HOOK_NAME} hook is not installed. Run `ccd hooks install` to add it."
            ),
        });
        return Ok(CheckReport {
            command: "hooks check",
            ok: true,
            checks,
        });
    }

    let contents = fs::read_to_string(&hook_path)?;
    let is_ccd = contents.contains(CCD_MARKER);

    if is_ccd {
        checks.push(HookCheck {
            hook: HOOK_NAME.to_owned(),
            status: "pass",
            severity: "info",
            message: format!("{HOOK_NAME} hook is installed and CCD-managed."),
        });
    } else {
        checks.push(HookCheck {
            hook: HOOK_NAME.to_owned(),
            status: "warn",
            severity: "warning",
            message: format!(
                "{HOOK_NAME} hook exists but is not CCD-managed. \
                 Run `ccd hooks install --force` to replace it."
            ),
        });
    }

    // Check executable permission.
    let metadata = fs::metadata(&hook_path)?;
    let perms = metadata.permissions();
    if perms.mode() & 0o111 == 0 {
        checks.push(HookCheck {
            hook: HOOK_NAME.to_owned(),
            status: "warn",
            severity: "warning",
            message: format!("{HOOK_NAME} hook is not executable."),
        });
    }

    let ok = !checks.iter().any(|c| c.severity == "error");
    Ok(CheckReport {
        command: "hooks check",
        ok,
        checks,
    })
}