netsky 0.1.5

netsky CLI: the viable system launcher and subcommand dispatcher
//! `netsky hooks ...` — install / uninstall / inspect the repo-tracked
//! pre-push hook.
//!
//! The canonical hook lives at `.githooks/pre-push` (tracked). `install`
//! creates `.git/hooks/pre-push` as a relative symlink to it, so that
//! `git pull` propagates hook changes automatically without re-running
//! install. Idempotent. `--force` overwrites a drifted local hook.
//!
//! Escape hatch: `SKIP_PREPUSH_CHECK=1 git push` (scripted paths that
//! already ran `bin/check`). The hook itself honours this env var.
//!
//! Why a symlink and not a copy: copies drift silently. A symlink that
//! follows the working tree means the hook IS the tracked file — one
//! source of truth.
//!
//! Why `.git/hooks/pre-push` and not `core.hooksPath`: the latter is a
//! per-clone git config mutation. Symlink leaves git config untouched
//! and is transparent to `git status`.
//!
//! Dogfood: the repo installing this gate is the same repo that just
//! shipped 13 red-main commits because there was no local gate.

use std::fs;
use std::io;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::cli::HooksCommand;

const CANONICAL_REL: &str = ".githooks/pre-push";
const HOOK_REL: &str = ".git/hooks/pre-push";
/// `.git/hooks/pre-push` → `../../.githooks/pre-push`. Relative so the
/// symlink travels with the working tree.
const SYMLINK_TARGET: &str = "../../.githooks/pre-push";

pub fn run(cmd: HooksCommand) -> netsky_core::Result<()> {
    let root = repo_root()?;
    match cmd {
        HooksCommand::Install { force } => install(&root, force),
        HooksCommand::Uninstall => uninstall(&root),
        HooksCommand::Status => status(&root),
    }
}

pub(crate) fn install(repo_root: &Path, force: bool) -> netsky_core::Result<()> {
    let canonical = repo_root.join(CANONICAL_REL);
    if !canonical.exists() {
        return Err(netsky_core::Error::Invalid(format!(
            "canonical hook missing: {} — run from a netsky checkout",
            canonical.display()
        )));
    }
    ensure_exec(&canonical)?;

    let target = repo_root.join(HOOK_REL);
    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent)?;
    }

    match classify(&target) {
        HookState::Absent => {
            symlink(SYMLINK_TARGET, &target)?;
            println!(
                "[hooks install] linked {} -> {SYMLINK_TARGET}",
                target.display()
            );
            Ok(())
        }
        HookState::CanonicalLink => {
            println!(
                "[hooks install] {} already canonical; no-op",
                target.display()
            );
            Ok(())
        }
        HookState::Drift => {
            if !force {
                print_drift(&target, &canonical)?;
                println!("[hooks install] re-run with --force to overwrite.");
                return Ok(());
            }
            fs::remove_file(&target)?;
            symlink(SYMLINK_TARGET, &target)?;
            println!(
                "[hooks install] replaced drifted hook at {}",
                target.display()
            );
            Ok(())
        }
    }
}

pub(crate) fn uninstall(repo_root: &Path) -> netsky_core::Result<()> {
    let target = repo_root.join(HOOK_REL);
    match target.symlink_metadata() {
        Ok(_) => {
            fs::remove_file(&target)?;
            println!("[hooks uninstall] removed {}", target.display());
        }
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            println!("[hooks uninstall] {} already absent", target.display());
        }
        Err(e) => return Err(netsky_core::Error::Io(e)),
    }
    Ok(())
}

pub(crate) fn status(repo_root: &Path) -> netsky_core::Result<()> {
    let canonical = repo_root.join(CANONICAL_REL);
    let target = repo_root.join(HOOK_REL);

    println!("canonical:    {}", canonical.display());
    println!(
        "  present:    {}",
        if canonical.exists() { "yes" } else { "NO" }
    );
    if canonical.exists() {
        println!("  executable: {}", yes_no(is_exec(&canonical)));
    }
    println!("hook:         {}", target.display());
    let state = classify(&target);
    println!(
        "  state:      {}",
        match state {
            HookState::Absent => "absent (run `netsky hooks install`)",
            HookState::CanonicalLink => "canonical symlink",
            HookState::Drift => "PRESENT BUT DRIFTED (run `netsky hooks install --force`)",
        }
    );
    if let Ok(link) = fs::read_link(&target) {
        println!("  link target: {}", link.display());
    }
    let bypass = std::env::var("SKIP_PREPUSH_CHECK").unwrap_or_default();
    println!(
        "bypass env:   SKIP_PREPUSH_CHECK={}",
        if bypass.is_empty() {
            "<unset>"
        } else {
            &bypass
        }
    );
    Ok(())
}

// ---- classification ------------------------------------------------------

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HookState {
    /// No file, no symlink at `.git/hooks/pre-push`.
    Absent,
    /// Symlink present and pointing at `SYMLINK_TARGET`.
    CanonicalLink,
    /// Something is there (regular file, wrong symlink, broken symlink)
    /// but it isn't the canonical symlink.
    Drift,
}

pub(crate) fn classify(target: &Path) -> HookState {
    match fs::symlink_metadata(target) {
        Err(_) => HookState::Absent,
        Ok(meta) => {
            if meta.file_type().is_symlink() {
                match fs::read_link(target) {
                    Ok(link) if link.as_path() == Path::new(SYMLINK_TARGET) => {
                        HookState::CanonicalLink
                    }
                    _ => HookState::Drift,
                }
            } else {
                HookState::Drift
            }
        }
    }
}

// ---- helpers -------------------------------------------------------------

fn repo_root() -> netsky_core::Result<PathBuf> {
    let out = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .map_err(netsky_core::Error::Io)?;
    if !out.status.success() {
        return Err(netsky_core::Error::Invalid(
            "not inside a git working tree".into(),
        ));
    }
    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if s.is_empty() {
        return Err(netsky_core::Error::Invalid(
            "git rev-parse returned empty root".into(),
        ));
    }
    Ok(PathBuf::from(s))
}

fn ensure_exec(path: &Path) -> netsky_core::Result<()> {
    let meta = fs::metadata(path).map_err(netsky_core::Error::Io)?;
    let mut perms = meta.permissions();
    let mode = perms.mode();
    if mode & 0o111 != 0o111 {
        perms.set_mode(mode | 0o755);
        fs::set_permissions(path, perms).map_err(netsky_core::Error::Io)?;
    }
    Ok(())
}

fn is_exec(path: &Path) -> bool {
    fs::metadata(path)
        .map(|m| m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

fn print_drift(target: &Path, canonical: &Path) -> netsky_core::Result<()> {
    let existing = fs::read_to_string(target).unwrap_or_default();
    let want = fs::read_to_string(canonical).map_err(netsky_core::Error::Io)?;
    println!(
        "[hooks install] {} exists and differs from {}:",
        target.display(),
        canonical.display()
    );
    println!("--- existing ---");
    println!("{}", existing.trim_end());
    println!("--- canonical ---");
    println!("{}", want.trim_end());
    Ok(())
}

fn yes_no(b: bool) -> &'static str {
    if b { "yes" } else { "NO" }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::symlink;
    use tempfile::tempdir;

    fn init_repo_with_canonical() -> tempfile::TempDir {
        let td = tempdir().unwrap();
        let root = td.path();
        fs::create_dir_all(root.join(".git/hooks")).unwrap();
        fs::create_dir_all(root.join(".githooks")).unwrap();
        fs::write(root.join(CANONICAL_REL), "#!/usr/bin/env bash\nexec true\n").unwrap();
        let mut perms = fs::metadata(root.join(CANONICAL_REL))
            .unwrap()
            .permissions();
        perms.set_mode(0o755);
        fs::set_permissions(root.join(CANONICAL_REL), perms).unwrap();
        td
    }

    #[test]
    fn classify_absent_on_empty_dir() {
        let td = tempdir().unwrap();
        assert_eq!(
            classify(&td.path().join(".git/hooks/pre-push")),
            HookState::Absent
        );
    }

    #[test]
    fn classify_canonical_on_correct_symlink() {
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        symlink(SYMLINK_TARGET, &target).unwrap();
        assert_eq!(classify(&target), HookState::CanonicalLink);
    }

    #[test]
    fn classify_drift_on_regular_file() {
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        fs::write(&target, "#!/bin/sh\necho other\n").unwrap();
        assert_eq!(classify(&target), HookState::Drift);
    }

    #[test]
    fn classify_drift_on_wrong_symlink() {
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        symlink("/dev/null", &target).unwrap();
        assert_eq!(classify(&target), HookState::Drift);
    }

    #[test]
    fn classify_canonical_points_at_missing_canonical() {
        // Document that classify() is pure symlink topology — a correct
        // link target returns CanonicalLink even when the canonical file
        // has since been deleted. doctor.rs is responsible for the
        // follow-on exists/exec health check.
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        symlink(SYMLINK_TARGET, &target).unwrap();
        fs::remove_file(td.path().join(CANONICAL_REL)).unwrap();
        assert_eq!(classify(&target), HookState::CanonicalLink);
    }

    #[test]
    fn install_from_clean_creates_symlink() {
        let td = init_repo_with_canonical();
        install(td.path(), false).unwrap();
        let target = td.path().join(HOOK_REL);
        assert_eq!(classify(&target), HookState::CanonicalLink);
        assert_eq!(
            fs::read_link(&target).unwrap(),
            PathBuf::from(SYMLINK_TARGET)
        );
    }

    #[test]
    fn install_is_idempotent() {
        let td = init_repo_with_canonical();
        install(td.path(), false).unwrap();
        install(td.path(), false).unwrap();
        install(td.path(), false).unwrap();
        assert_eq!(
            classify(&td.path().join(HOOK_REL)),
            HookState::CanonicalLink
        );
    }

    #[test]
    fn install_without_force_leaves_drift_intact() {
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        fs::write(&target, "#!/bin/sh\necho legacy\n").unwrap();
        install(td.path(), false).unwrap();
        assert_eq!(classify(&target), HookState::Drift);
        assert_eq!(
            fs::read_to_string(&target).unwrap(),
            "#!/bin/sh\necho legacy\n"
        );
    }

    #[test]
    fn install_with_force_replaces_drift() {
        let td = init_repo_with_canonical();
        let target = td.path().join(HOOK_REL);
        fs::write(&target, "#!/bin/sh\necho legacy\n").unwrap();
        install(td.path(), true).unwrap();
        assert_eq!(classify(&target), HookState::CanonicalLink);
    }

    #[test]
    fn install_missing_canonical_errors() {
        let td = tempdir().unwrap();
        fs::create_dir_all(td.path().join(".git/hooks")).unwrap();
        let err = install(td.path(), false).unwrap_err();
        assert!(
            matches!(err, netsky_core::Error::Invalid(ref m) if m.contains("canonical hook missing"))
        );
    }

    #[test]
    fn uninstall_removes_symlink() {
        let td = init_repo_with_canonical();
        install(td.path(), false).unwrap();
        uninstall(td.path()).unwrap();
        assert_eq!(classify(&td.path().join(HOOK_REL)), HookState::Absent);
    }

    #[test]
    fn uninstall_absent_is_noop() {
        let td = init_repo_with_canonical();
        uninstall(td.path()).unwrap();
        assert_eq!(classify(&td.path().join(HOOK_REL)), HookState::Absent);
    }

    #[test]
    fn install_makes_canonical_executable() {
        let td = init_repo_with_canonical();
        let canonical = td.path().join(CANONICAL_REL);
        let mut p = fs::metadata(&canonical).unwrap().permissions();
        p.set_mode(0o644);
        fs::set_permissions(&canonical, p).unwrap();
        install(td.path(), false).unwrap();
        assert!(is_exec(&canonical));
    }
}