specsync 3.6.2

Bidirectional spec-to-code validation with schema column checking — 11 languages, single binary
mod ai;
mod archive;
mod changelog;
mod cli;
mod commands;
mod comment;
mod compact;
mod config;
mod deps;
mod exports;
mod generator;
mod github;
mod hash_cache;
mod hooks;
mod ignore;
mod importer;
mod manifest;
mod mcp;
mod merge;
mod output;
mod parser;
mod registry;
mod schema;
mod scoring;
mod types;
mod validator;
mod view;
mod watch;

use clap::Parser;
use colored::Colorize;
use std::process;

use cli::{Cli, Command};

fn main() {
    let result = std::panic::catch_unwind(run);
    match result {
        Ok(()) => {}
        Err(e) => {
            let msg = if let Some(s) = e.downcast_ref::<&str>() {
                s.to_string()
            } else if let Some(s) = e.downcast_ref::<String>() {
                s.clone()
            } else {
                "unknown error".to_string()
            };
            eprintln!(
                "{} specsync panicked: {msg}\n\nThis is a bug — please report it at https://github.com/CorvidLabs/spec-sync/issues",
                "Error:".red().bold()
            );
            process::exit(1);
        }
    }
}

fn run() {
    let cli = Cli::parse();
    let root = cli
        .root
        .unwrap_or_else(|| std::env::current_dir().expect("Cannot determine cwd"));
    let root = root.canonicalize().unwrap_or(root);

    // --json flag is shorthand for --format json (backward compat)
    let format = if cli.json {
        types::OutputFormat::Json
    } else {
        cli.format
    };

    let command = cli.command.unwrap_or(Command::Check {
        fix: false,
        force: false,
        create_issues: false,
        explain: false,
        specs: vec![],
    });

    match command {
        Command::Init => commands::init::cmd_init(&root),
        Command::Check {
            fix,
            force,
            create_issues,
            explain,
            specs,
        } => commands::check::cmd_check(
            &root,
            cli.strict,
            cli.enforcement,
            cli.require_coverage,
            format,
            fix,
            force,
            create_issues,
            explain,
            &specs,
        ),
        Command::Coverage => commands::coverage::cmd_coverage(
            &root,
            cli.strict,
            cli.enforcement,
            cli.require_coverage,
            format,
        ),
        Command::Generate { provider } => commands::generate::cmd_generate(
            &root,
            cli.strict,
            cli.enforcement,
            cli.require_coverage,
            format,
            provider,
        ),
        Command::Score { explain, specs } => {
            commands::score::cmd_score(&root, format, explain, &specs)
        }
        Command::Watch => watch::run_watch(&root, cli.strict, cli.require_coverage),
        Command::Mcp => mcp::run_mcp_server(&root),
        Command::AddSpec { name } => commands::scaffold::cmd_add_spec(&root, &name),
        Command::Scaffold {
            name,
            dir,
            template,
        } => commands::scaffold::cmd_scaffold(&root, &name, dir, template),
        Command::InitRegistry { name } => commands::init_registry::cmd_init_registry(&root, name),
        Command::Resolve { remote } => commands::resolve::cmd_resolve(&root, remote),
        Command::Diff { base } => commands::diff::cmd_diff(&root, &base, format),
        Command::Hooks { action } => commands::hooks::cmd_hooks(&root, action),
        Command::Compact { keep, dry_run } => commands::compact::cmd_compact(&root, keep, dry_run),
        Command::ArchiveTasks { dry_run } => {
            commands::archive_tasks::cmd_archive_tasks(&root, dry_run)
        }
        Command::View { role, spec } => commands::view::cmd_view(&root, &role, spec.as_deref()),
        Command::Merge { dry_run, all } => commands::merge::cmd_merge(&root, dry_run, all, format),
        Command::Issues { create } => commands::issues::cmd_issues(&root, format, create),
        Command::New { name, full } => commands::new::cmd_new(&root, &name, full),
        Command::Wizard => commands::wizard::cmd_wizard(&root),
        Command::Deps { mermaid, dot } => commands::deps::cmd_deps(&root, format, mermaid, dot),
        Command::Import { source, id, repo } => {
            commands::import::cmd_import(&root, &source, &id, repo.as_deref())
        }
        Command::Report { stale_threshold } => {
            commands::report::cmd_report(&root, format, stale_threshold)
        }
        Command::Comment { pr, base } => commands::comment::cmd_comment(&root, pr, &base),
        Command::Changelog { range } => commands::changelog::cmd_changelog(&root, &range, format),
    }
}

#[cfg(test)]
mod tests {
    use crate::commands::compute_exit_code;
    use crate::types;

    fn empty_coverage() -> types::CoverageReport {
        types::CoverageReport {
            total_source_files: 0,
            specced_file_count: 0,
            unspecced_files: vec![],
            unspecced_modules: vec![],
            coverage_percent: 100,
            total_loc: 0,
            specced_loc: 0,
            loc_coverage_percent: 100,
            unspecced_file_loc: vec![],
        }
    }

    fn coverage_with_unspecced(files: Vec<&str>) -> types::CoverageReport {
        let total = files.len();
        types::CoverageReport {
            total_source_files: total,
            specced_file_count: 0,
            unspecced_files: files.iter().map(|s| s.to_string()).collect(),
            unspecced_modules: vec![],
            coverage_percent: 0,
            total_loc: 0,
            specced_loc: 0,
            loc_coverage_percent: 0,
            unspecced_file_loc: vec![],
        }
    }

    // ─── Warn mode ───────────────────────────────────────────────────────────

    #[test]
    fn warn_mode_exits_0_with_no_errors() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(0, 0, false, types::EnforcementMode::Warn, &coverage, None),
            0
        );
    }

    #[test]
    fn warn_mode_exits_0_even_with_errors() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(5, 3, false, types::EnforcementMode::Warn, &coverage, None),
            0
        );
    }

    #[test]
    fn warn_mode_exits_0_even_with_strict_flag() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(0, 3, true, types::EnforcementMode::Warn, &coverage, None),
            0
        );
    }

    #[test]
    fn warn_mode_respects_require_coverage() {
        let coverage = types::CoverageReport {
            coverage_percent: 50,
            ..empty_coverage()
        };
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::Warn,
                &coverage,
                Some(80)
            ),
            1
        );
    }

    // ─── EnforceNew mode ─────────────────────────────────────────────────────

    #[test]
    fn enforce_new_exits_0_when_all_files_specced() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::EnforceNew,
                &coverage,
                None
            ),
            0
        );
    }

    #[test]
    fn enforce_new_exits_0_with_errors_if_all_specced() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(
                3,
                2,
                false,
                types::EnforcementMode::EnforceNew,
                &coverage,
                None
            ),
            0
        );
    }

    #[test]
    fn enforce_new_exits_1_when_unspecced_files_exist() {
        let coverage = coverage_with_unspecced(vec!["src/foo.rs"]);
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::EnforceNew,
                &coverage,
                None
            ),
            1
        );
    }

    #[test]
    fn enforce_new_exits_1_with_multiple_unspecced_files() {
        let coverage = coverage_with_unspecced(vec!["src/foo.rs", "src/bar.rs"]);
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::EnforceNew,
                &coverage,
                None
            ),
            1
        );
    }

    // ─── Strict mode ─────────────────────────────────────────────────────────

    #[test]
    fn strict_mode_exits_0_with_no_errors() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(0, 0, false, types::EnforcementMode::Strict, &coverage, None),
            0
        );
    }

    #[test]
    fn strict_mode_exits_1_with_errors() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(1, 0, false, types::EnforcementMode::Strict, &coverage, None),
            1
        );
    }

    #[test]
    fn strict_mode_exits_0_with_warnings_only() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(0, 3, false, types::EnforcementMode::Strict, &coverage, None),
            0
        );
    }

    #[test]
    fn strict_mode_exits_1_with_warnings_and_strict_flag() {
        let coverage = empty_coverage();
        assert_eq!(
            compute_exit_code(0, 3, true, types::EnforcementMode::Strict, &coverage, None),
            1
        );
    }

    #[test]
    fn strict_mode_respects_require_coverage() {
        let coverage = types::CoverageReport {
            coverage_percent: 70,
            ..empty_coverage()
        };
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::Strict,
                &coverage,
                Some(80)
            ),
            1
        );
    }

    #[test]
    fn strict_mode_exits_0_when_coverage_meets_threshold() {
        let coverage = types::CoverageReport {
            coverage_percent: 85,
            ..empty_coverage()
        };
        assert_eq!(
            compute_exit_code(
                0,
                0,
                false,
                types::EnforcementMode::Strict,
                &coverage,
                Some(80)
            ),
            0
        );
    }
}