klasp 0.4.0

Block AI coding agents on the same quality gates your humans hit. See https://github.com/klasp-dev/klasp
Documentation
//! `klasp init` — scaffold a `klasp.toml` in the current repo.
//!
//! Resolves the repo root via the shared `resolve_repo_root` helper, then
//! atomically writes an example `klasp.toml` that parses cleanly through
//! `ConfigV1::parse`. Keep [`EXAMPLE_TOML`] in sync when the config schema
//! grows. See [docs/design.md §5].
//!
//! Pass `--force` to overwrite an existing file without prompting.
//! Pass `--adopt` to detect existing gates and propose a mirrored config.
//! Combine `--adopt` with `--mode mirror` to write the config, or use
//! `--mode inspect` (default) to only print the plan without writing.
//!
//! When `--mode mirror` is used, the writer narrows `[gate].agents` to the
//! agents actually installed on this machine (detected via
//! [`crate::adopt::detect_agents::detect_installed_agents`]). See
//! klasp-dev/klasp#103.

use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::{anyhow, Context, Result};

use crate::adopt::plan::AdoptMode;
use crate::cli::{AdoptModeArg, InitArgs};

/// Example `klasp.toml` written by `klasp init`. Every uncommented line
/// must parse cleanly via `klasp_core::ConfigV1::parse`.
const EXAMPLE_TOML: &str = r#"# klasp.toml — generated by `klasp init`
# Docs: https://github.com/klasp-dev/klasp
# Verify this install: run `klasp doctor`

version = 1

[gate]
# Agent surfaces that klasp intercepts. v0.3 ships three: claude_code, codex, aider.
# `klasp install --agent all` walks this list. Comment out any you don't use.
agents = ["claude_code", "codex", "aider"]
# Gate policy: "any_fail" blocks the agent if any check fails.
policy = "any_fail"

# [[checks]]
# name = "lint"
# triggers = [{ on = ["commit"] }]
# timeout_secs = 60
# [checks.source]
# type = "shell"
# command = "ruff check ."   # or: cargo clippy --all-targets -- -D warnings

# [[checks]]
# name = "test"
# triggers = [{ on = ["commit"] }]
# [checks.source]
# type = "shell"
# command = "pytest -q"      # or: cargo test --workspace
"#;

pub fn run(args: &InitArgs) -> ExitCode {
    if args.adopt || args.mode != AdoptModeArg::Inspect {
        run_adopt(args)
    } else {
        match try_run(args) {
            Ok(path) => {
                println!("wrote {}", path.display());
                ExitCode::SUCCESS
            }
            Err(e) => {
                eprintln!("klasp init: {e:#}");
                ExitCode::FAILURE
            }
        }
    }
}

/// Adoption dispatch: detect gates, then inspect / mirror / chain.
fn run_adopt(args: &InitArgs) -> ExitCode {
    let repo_root =
        match crate::cmd::install::resolve_repo_root(None).context("resolving repo root") {
            Ok(r) => r,
            Err(e) => {
                eprintln!("klasp init: {e:#}");
                return ExitCode::FAILURE;
            }
        };

    let plan = match crate::adopt::detect::detect_all(&repo_root) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("klasp init: detecting gates: {e}");
            return ExitCode::FAILURE;
        }
    };

    let mode: AdoptMode = args.mode.into();

    match mode {
        AdoptMode::Inspect => {
            print!("{}", crate::adopt::render::render_plan(&plan));
            ExitCode::SUCCESS
        }
        AdoptMode::Mirror => {
            print!("{}", crate::adopt::render::render_plan(&plan));

            // Detect which agents are installed on this machine and narrow
            // the agents list so doctor exits clean on a fresh install.
            let home = crate::fs_util::home_dir();
            let (detected_agents, fell_back) =
                crate::adopt::detect_agents::detect_installed_agents(home.as_deref());
            // None signals the writer to emit the three-agent default with the
            // "edit me" comment (AC #6); only set when detection actually fell
            // back, not when the user genuinely has all three agents installed.
            let agents_arg = narrowed_agents_arg(&detected_agents, fell_back);

            match crate::adopt::writer::write_klasp_toml(&repo_root, &plan, args.force, agents_arg)
            {
                Ok(path) => {
                    println!("wrote klasp.toml at {}", path.display());
                    ExitCode::SUCCESS
                }
                Err(e) => {
                    eprintln!("klasp init: {e}");
                    ExitCode::FAILURE
                }
            }
        }
        AdoptMode::Chain => {
            let msg = crate::adopt::mode::chain_unsupported_message(&plan);
            eprint!("{msg}");
            ExitCode::from(2)
        }
    }
}

/// Convert a detected agents list + fallback flag into the
/// `Option<&[String]>` expected by `write_klasp_toml`.
///
/// Returns `None` when detection fell back (no agents found on the machine),
/// so the writer emits today's three-agent default with the "edit me"
/// comment (AC #6: nothing detected → default + comment).
/// Returns `Some(detected)` when at least one agent was actually detected,
/// so the writer uses the narrowed list without the fallback comment.
fn narrowed_agents_arg(detected: &[String], fell_back: bool) -> Option<&[String]> {
    if fell_back {
        None
    } else {
        Some(detected)
    }
}

fn try_run(args: &InitArgs) -> Result<PathBuf> {
    let repo_root = crate::cmd::install::resolve_repo_root(None).context("resolving repo root")?;
    let target = repo_root.join("klasp.toml");

    if target.exists() && !args.force {
        return Err(anyhow!(
            "klasp.toml already exists at {}; pass --force to overwrite",
            target.display()
        ));
    }

    crate::fs_util::atomic_write_text(&target, EXAMPLE_TOML)
        .with_context(|| format!("writing klasp.toml to {}", target.display()))?;

    Ok(target)
}

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

    /// AC #6: when detection fell back, return None so the writer emits the
    /// 3-agent default with the "edit me" comment.
    #[test]
    fn narrowed_agents_arg_fallback_returns_none() {
        let fallback = vec![
            "claude_code".to_string(),
            "codex".to_string(),
            "aider".to_string(),
        ];
        assert!(narrowed_agents_arg(&fallback, true).is_none());
    }

    /// AC #6: when detection genuinely found agents, return Some(detected)
    /// so the writer narrows without the fallback comment.
    #[test]
    fn narrowed_agents_arg_detected_returns_some() {
        let agents = vec!["claude_code".to_string()];
        assert_eq!(narrowed_agents_arg(&agents, false), Some(agents.as_slice()));
    }

    /// User with all three agents present is NOT a fallback — narrow with the
    /// real list, no edit-me comment.
    #[test]
    fn narrowed_agents_arg_user_with_all_three_is_some() {
        let agents = vec![
            "claude_code".to_string(),
            "codex".to_string(),
            "aider".to_string(),
        ];
        assert_eq!(narrowed_agents_arg(&agents, false), Some(agents.as_slice()));
    }
}