smackdebt 0.3.0

Find costly code debt and see whether your changes make it better
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

use crate::terminal::ColorChoice;

#[derive(Debug, Parser)]
#[command(
    name = "smackdebt",
    version,
    about = "Less spaghetti. Help your agent write code you can follow.",
    after_help = "Examples:\n  smackdebt                 Find the trouble spots\n  smackdebt src/auth        Look closer\n  smackdebt diff main       Check what changed\n  smackdebt init            Set up your agents\n  smackdebt gate            Check the baseline"
)]
pub(crate) struct Cli {
    #[command(subcommand)]
    pub(crate) command: Option<Command>,
    /// Show one path.
    pub(crate) path: Option<PathBuf>,
    #[command(flatten)]
    pub(crate) common: Common,
}

#[derive(Debug, Subcommand)]
pub(crate) enum Command {
    /// Install or update the skill for your coding agents.
    Init(crate::init::InitArgs),
    /// Internal command used by optional agent hooks.
    #[command(name = "__hook", hide = true)]
    Hook(crate::agent_hook::HookArgs),
    /// Compare your current work with a Git ref.
    #[command(
        after_help = "Examples:\n  smackdebt diff main\n  smackdebt diff main src/auth\n  smackdebt diff --json"
    )]
    Diff(DiffArgs),
    /// Check debt against a committed baseline.
    #[command(
        after_help = "Examples:\n  smackdebt gate\n  smackdebt gate --json\n  smackdebt gate --update    Accept the current debt as your baseline"
    )]
    Gate(GateArgs),
}

#[derive(Debug, Args)]
pub(crate) struct DiffArgs {
    /// Git ref. If omitted, uses origin/HEAD, main, or master.
    pub(crate) reference: Option<String>,
    /// Show one changed path.
    pub(crate) path: Option<PathBuf>,
    #[command(flatten)]
    pub(crate) common: Common,
}

#[derive(Debug, Args)]
pub(crate) struct GateArgs {
    /// Check one path.
    pub(crate) path: Option<PathBuf>,
    /// Baseline file. If omitted, uses .smackdebt-baseline.tsv at the path.
    #[arg(long)]
    pub(crate) baseline: Option<PathBuf>,
    /// Write the observed debt as the new baseline.
    #[arg(long, conflicts_with = "json")]
    pub(crate) update: bool,
    /// Write the gate result as JSON.
    #[arg(long, conflicts_with = "color")]
    pub(crate) json: bool,
    /// Number of workers to use.
    #[arg(long, value_parser = parse_jobs)]
    pub(crate) jobs: Option<usize>,
}

#[derive(Clone, Debug, Args)]
pub(crate) struct Common {
    /// Write the complete JSON report.
    #[arg(long, conflicts_with = "color", help_heading = "Output")]
    pub(crate) json: bool,
    /// Number of workers to use.
    #[arg(long, value_parser = parse_jobs)]
    pub(crate) jobs: Option<usize>,
    /// Recent activity window, such as 90d.
    #[arg(long, value_parser = parse_days)]
    pub(crate) history: Option<u32>,
    /// Show all useful terminal detail.
    #[arg(long, help_heading = "Output")]
    pub(crate) all: bool,
    /// Show up to this many problems or comparisons.
    #[arg(long, value_parser = parse_top, conflicts_with_all = ["json", "all"], help_heading = "Output")]
    pub(crate) top: Option<usize>,
    /// Terminal color and decoration: auto, always, or never.
    #[arg(long, value_enum, global = true, help_heading = "Output")]
    pub(crate) color: Option<ColorChoice>,
}

pub(crate) fn parse_days(value: &str) -> Result<u32, String> {
    let days = value
        .strip_suffix('d')
        .ok_or_else(|| "use days, for example 90d".to_owned())?
        .parse::<u32>()
        .map_err(|_| "use a whole number of days".to_owned())?;
    if days == 0 {
        return Err("use at least one day".to_owned());
    }
    Ok(days)
}

fn parse_top(value: &str) -> Result<usize, String> {
    let top = value
        .parse::<usize>()
        .map_err(|_| "use a whole number greater than zero".to_owned())?;
    if top == 0 {
        return Err("use a whole number greater than zero".to_owned());
    }
    Ok(top)
}

fn parse_jobs(value: &str) -> Result<usize, String> {
    let jobs = value
        .parse::<usize>()
        .map_err(|_| "use a whole number greater than zero".to_owned())?;
    if jobs == 0 {
        return Err("use a whole number greater than zero".to_owned());
    }
    Ok(jobs)
}

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

    #[test]
    fn parses_history_days() {
        assert_eq!(parse_days("180d"), Ok(180));
        assert!(parse_days("0d").is_err());
        assert!(parse_days("90").is_err());
    }

    #[test]
    fn rejects_zero_jobs() {
        assert!(Cli::try_parse_from(["smackdebt", "--jobs", "0"]).is_err());
    }

    #[test]
    fn all_and_json_are_rejected_by_the_command_not_by_usage_text() {
        let cli = Cli::try_parse_from(["smackdebt", "--json", "--all"]).unwrap();
        assert!(cli.common.json && cli.common.all);
    }

    #[test]
    fn explicit_color_conflicts_with_json() {
        assert!(Cli::try_parse_from(["smackdebt", "--json", "--color", "always"]).is_err());
    }

    #[test]
    fn rejects_a_zero_finding_limit() {
        assert!(Cli::try_parse_from(["smackdebt", "--top", "0"]).is_err());
        let cli = Cli::try_parse_from(["smackdebt", "--top", "5"]).unwrap();
        assert_eq!(cli.common.top, Some(5));
    }

    #[test]
    fn a_baseline_update_conflicts_with_gate_json() {
        assert!(Cli::try_parse_from(["smackdebt", "gate", "--update", "--json"]).is_err());
        let cli = Cli::try_parse_from(["smackdebt", "gate", "--update"]).unwrap();
        let Some(Command::Gate(args)) = cli.command else {
            panic!("expected the gate subcommand");
        };
        assert!(args.update && !args.json);
    }

    #[test]
    fn a_finding_limit_conflicts_with_json_and_with_all() {
        assert!(Cli::try_parse_from(["smackdebt", "--top", "5", "--json"]).is_err());
        assert!(Cli::try_parse_from(["smackdebt", "--top", "5", "--all"]).is_err());
    }
}