Skip to main content

sdivi_cli/
lib.rs

1mod commands;
2mod logging;
3mod output;
4
5use std::path::PathBuf;
6
7use clap::{Parser, Subcommand};
8use sdivi_core::ExitCode;
9
10use commands::boundaries::BoundariesSubcmd;
11
12/// Structural Divergence Indexer — measure structural drift in your codebase.
13#[derive(Parser)]
14#[command(name = "sdivi", version, about, long_about = None)]
15struct Cli {
16    /// Repository root (default: current directory).
17    #[arg(long, default_value = ".")]
18    repo: PathBuf,
19
20    #[command(subcommand)]
21    command: Option<Commands>,
22}
23
24#[derive(Subcommand)]
25enum Commands {
26    /// Initialize `.sdivi/` and write a default config.
27    Init,
28    /// Build and display the pattern catalog for the repository.
29    Catalog {
30        /// Output format: `text` (default) or `json`.
31        #[arg(long, default_value = "text")]
32        format: String,
33    },
34    /// Capture a snapshot of the repository's current structural state.
35    Snapshot {
36        /// Git commit SHA to record (optional).
37        #[arg(long)]
38        commit: Option<String>,
39        /// Output format: `text` (default) or `json`.
40        #[arg(long, default_value = "text")]
41        format: String,
42    },
43    /// Compare two snapshots and display the divergence summary.
44    Diff {
45        /// Path to the previous (older) snapshot JSON file.
46        prev: PathBuf,
47        /// Path to the current (newer) snapshot JSON file.
48        curr: PathBuf,
49        /// Output format: `text` (default) or `json`.
50        #[arg(long, default_value = "text")]
51        format: String,
52    },
53    /// Capture a snapshot, compare to prior, and exit 10 if thresholds are exceeded.
54    Check {
55        /// Skip writing the new snapshot to `.sdivi/snapshots/` (retention not enforced).
56        #[arg(long)]
57        no_write: bool,
58        /// Output format: `text` (default) or `json`.
59        #[arg(long, default_value = "text")]
60        format: String,
61    },
62    /// Show trend statistics across stored snapshots.
63    Trend {
64        /// Number of most-recent snapshots to include (default: all).
65        #[arg(long)]
66        last: Option<usize>,
67        /// Output format: `text` (default) or `json`.
68        #[arg(long, default_value = "text")]
69        format: String,
70    },
71    /// Inspect a stored snapshot.
72    Show {
73        /// Snapshot id (filename stem without `.json`); defaults to the latest.
74        id: Option<String>,
75        /// Output format: `text` (default) or `json`.
76        #[arg(long, default_value = "text")]
77        format: String,
78    },
79    /// Manage declared module boundaries (infer, ratify, show).
80    Boundaries {
81        #[command(subcommand)]
82        subcmd: BoundariesSubcmd,
83    },
84}
85
86/// Entry point for the `sdivi` binary.
87pub fn run() {
88    let cli = Cli::parse();
89    logging::init();
90
91    let config = match sdivi_config::load_or_default(&cli.repo) {
92        Ok(c) => c,
93        Err(e) => {
94            eprintln!("sdivi: error: {e:#}");
95            std::process::exit(ExitCode::ConfigError.as_i32());
96        }
97    };
98
99    // `check` returns ExitCode directly (may be 10); handle it before the
100    // standard Result<()> dispatch so exit-10 is not conflated with an error.
101    if let Some(Commands::Check { no_write, format }) = &cli.command {
102        match commands::check::run(&cli.repo, &config, *no_write, format) {
103            Ok(code) => std::process::exit(code.as_i32()),
104            Err(e) => {
105                eprintln!("sdivi: error: {e:#}");
106                std::process::exit(error_exit_code(&e).as_i32());
107            }
108        }
109    }
110
111    let result = match cli.command {
112        Some(Commands::Init) => commands::init::run(&cli.repo),
113        Some(Commands::Catalog { format }) => commands::catalog::run(&cli.repo, &config, &format),
114        Some(Commands::Snapshot { commit, format }) => {
115            commands::snapshot::run(&cli.repo, &config, commit.as_deref(), &format)
116        }
117        Some(Commands::Diff { prev, curr, format }) => commands::diff::run(&prev, &curr, &format),
118        Some(Commands::Check { .. }) => unreachable!("handled above"),
119        Some(Commands::Trend { last, format }) => {
120            commands::trend::run(&cli.repo, &config, last, &format)
121        }
122        Some(Commands::Show { id, format }) => {
123            commands::show::run(&cli.repo, &config, id.as_deref(), &format)
124        }
125        Some(Commands::Boundaries { subcmd }) => {
126            commands::boundaries::run(subcmd, &cli.repo, &config)
127        }
128        None => {
129            eprintln!("sdivi: no subcommand given — try `sdivi --help`");
130            return;
131        }
132    };
133
134    if let Err(e) = result {
135        let code = error_exit_code(&e);
136        eprintln!("sdivi: error: {e:#}");
137        std::process::exit(code.as_i32());
138    }
139}
140
141/// Maps an `anyhow::Error` to the appropriate [`ExitCode`].
142///
143/// `ConfigError` sources → [`ExitCode::ConfigError`] (2).
144/// `PipelineError::NoGrammarsAvailable` → [`ExitCode::AnalysisError`] (3).
145/// All other errors → [`ExitCode::RuntimeError`] (1).
146fn error_exit_code(e: &anyhow::Error) -> ExitCode {
147    if e.downcast_ref::<sdivi_config::ConfigError>().is_some() {
148        return ExitCode::ConfigError;
149    }
150    if let Some(pe) = e.downcast_ref::<sdivi_pipeline::PipelineError>() {
151        if matches!(pe, sdivi_pipeline::PipelineError::NoGrammarsAvailable) {
152            return ExitCode::AnalysisError;
153        }
154    }
155    ExitCode::RuntimeError
156}