|
| 1 | +// SPDX-FileCopyrightText: 2026 RAprogramm <andrey.rozanov.vl@gmail.com> |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +//! Library half of the `rustmanifest` CLI binary. |
| 5 | +//! |
| 6 | +//! The binary in `src/main.rs` is a thin shim that delegates to [`run`], |
| 7 | +//! which makes the command surface testable from integration tests without |
| 8 | +//! spawning a child process. |
| 9 | +
|
| 10 | +use std::{ |
| 11 | + io::{IsTerminal, Write}, |
| 12 | + path::PathBuf, |
| 13 | + process::ExitCode |
| 14 | +}; |
| 15 | + |
| 16 | +use anyhow::Context; |
| 17 | +use clap::{Parser, Subcommand, ValueEnum}; |
| 18 | +use rustmanifest_engine::{OrchestratorBuilder, PatternAnalyzer, walker}; |
| 19 | +use rustmanifest_report::{JsonRenderer, Renderer, SarifRenderer, TtyRenderer}; |
| 20 | +use rustmanifest_rules_core::RULES; |
| 21 | +use rustmanifest_schema::{Finding, RuleDefinition, Severity}; |
| 22 | +use tracing::info; |
| 23 | + |
| 24 | +/// Default exit code: no findings at or above the requested severity. |
| 25 | +pub const EXIT_CLEAN: u8 = 0; |
| 26 | +/// Findings present at or above the requested severity. |
| 27 | +pub const EXIT_FINDINGS: u8 = 1; |
| 28 | +/// Operational failure (IO, argument parsing, walker error). |
| 29 | +pub const EXIT_ERROR: u8 = 2; |
| 30 | + |
| 31 | +/// Top-level CLI arguments. |
| 32 | +#[derive(Debug, Parser)] |
| 33 | +#[command( |
| 34 | + name = "rustmanifest", |
| 35 | + version, |
| 36 | + about = "Production-grade Rust review engine — methodology-as-code" |
| 37 | +)] |
| 38 | +pub struct Cli { |
| 39 | + /// Increase verbosity. May be repeated (`-v`, `-vv`, `-vvv`). |
| 40 | + #[arg(short, long, action = clap::ArgAction::Count, global = true)] |
| 41 | + pub verbose: u8, |
| 42 | + |
| 43 | + /// Selected subcommand. |
| 44 | + #[command(subcommand)] |
| 45 | + pub command: Command |
| 46 | +} |
| 47 | + |
| 48 | +/// Available subcommands for the `rustmanifest` binary. |
| 49 | +#[derive(Debug, Subcommand)] |
| 50 | +pub enum Command { |
| 51 | + /// Run the analysis engine across one or more paths. |
| 52 | + Check(CheckArgs) |
| 53 | +} |
| 54 | + |
| 55 | +/// Arguments for the `check` subcommand. |
| 56 | +#[derive(Debug, Parser)] |
| 57 | +pub struct CheckArgs { |
| 58 | + /// Files or directories to analyze. Defaults to the current directory. |
| 59 | + #[arg(default_value = ".")] |
| 60 | + pub paths: Vec<PathBuf>, |
| 61 | + |
| 62 | + /// Output format. |
| 63 | + #[arg(long, value_enum, default_value_t = OutputFormat::Auto)] |
| 64 | + pub format: OutputFormat, |
| 65 | + |
| 66 | + /// Minimum severity to report. |
| 67 | + #[arg(long, value_enum, default_value_t = SeverityFilter::Hint)] |
| 68 | + pub severity_filter: SeverityFilter, |
| 69 | + |
| 70 | + /// Force-disable color output even on a TTY. |
| 71 | + #[arg(long)] |
| 72 | + pub no_color: bool, |
| 73 | + |
| 74 | + /// Per-file memory budget in bytes. |
| 75 | + #[arg(long, default_value_t = rustmanifest_engine::orchestrator::DEFAULT_MAX_FILE_BYTES)] |
| 76 | + pub max_file_size: u64 |
| 77 | +} |
| 78 | + |
| 79 | +/// Output format options. |
| 80 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] |
| 81 | +pub enum OutputFormat { |
| 82 | + /// Picks `tty` on a terminal, `json` otherwise. |
| 83 | + Auto, |
| 84 | + /// Canonical pretty-printed JSON array. |
| 85 | + Json, |
| 86 | + /// SARIF 2.1.0 document. |
| 87 | + Sarif, |
| 88 | + /// Human-readable terminal output. |
| 89 | + Tty |
| 90 | +} |
| 91 | + |
| 92 | +/// Minimum severity reported in the output. |
| 93 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] |
| 94 | +pub enum SeverityFilter { |
| 95 | + /// Report `error` only. |
| 96 | + Error, |
| 97 | + /// Report `error` and `warning`. |
| 98 | + Warning, |
| 99 | + /// Report `error`, `warning`, and `info`. |
| 100 | + Info, |
| 101 | + /// Report every severity. |
| 102 | + Hint |
| 103 | +} |
| 104 | + |
| 105 | +impl SeverityFilter { |
| 106 | + const fn level(self) -> u8 { |
| 107 | + match self { |
| 108 | + Self::Error => 3, |
| 109 | + Self::Warning => 2, |
| 110 | + Self::Info => 1, |
| 111 | + Self::Hint => 0 |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + const fn admits(self, severity: Severity) -> bool { |
| 116 | + let severity_level: u8 = match severity { |
| 117 | + Severity::Error => 3, |
| 118 | + Severity::Warning => 2, |
| 119 | + Severity::Info => 1, |
| 120 | + Severity::Hint => 0 |
| 121 | + }; |
| 122 | + severity_level >= self.level() |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +/// Entry point used by both the binary and integration tests. |
| 127 | +/// |
| 128 | +/// # Errors |
| 129 | +/// |
| 130 | +/// Returns an [`anyhow::Error`] for unrecoverable operational failures. |
| 131 | +pub fn run(cli: Cli) -> anyhow::Result<ExitCode> { |
| 132 | + init_tracing(cli.verbose); |
| 133 | + match cli.command { |
| 134 | + Command::Check(args) => run_check(&args) |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +fn init_tracing(verbose: u8) { |
| 139 | + let level = match verbose { |
| 140 | + 0 => tracing::Level::WARN, |
| 141 | + 1 => tracing::Level::INFO, |
| 142 | + 2 => tracing::Level::DEBUG, |
| 143 | + _ => tracing::Level::TRACE |
| 144 | + }; |
| 145 | + let subscriber = tracing_subscriber::fmt() |
| 146 | + .with_max_level(level) |
| 147 | + .with_target(false) |
| 148 | + .with_writer(std::io::stderr) |
| 149 | + .finish(); |
| 150 | + let _ = tracing::subscriber::set_global_default(subscriber); |
| 151 | +} |
| 152 | + |
| 153 | +fn run_check(args: &CheckArgs) -> anyhow::Result<ExitCode> { |
| 154 | + let analyzers: Vec<Box<dyn rustmanifest_engine::Analyzer>> = RULES |
| 155 | + .iter() |
| 156 | + .filter(|rule| matches!(rule.definition, RuleDefinition::Pattern { .. })) |
| 157 | + .map(|rule| { |
| 158 | + PatternAnalyzer::new(rule.clone()) |
| 159 | + .map(|analyzer| Box::new(analyzer) as Box<dyn rustmanifest_engine::Analyzer>) |
| 160 | + }) |
| 161 | + .collect::<Result<Vec<_>, _>>() |
| 162 | + .context("building pattern analyzers from bundled rules")?; |
| 163 | + |
| 164 | + info!( |
| 165 | + analyzer_count = analyzers.len(), |
| 166 | + path_count = args.paths.len(), |
| 167 | + "running check" |
| 168 | + ); |
| 169 | + |
| 170 | + let orchestrator = OrchestratorBuilder::new() |
| 171 | + .analyzers(analyzers) |
| 172 | + .max_file_bytes(args.max_file_size) |
| 173 | + .build(); |
| 174 | + |
| 175 | + let files = walker::discover(&args.paths).context("file discovery failed")?; |
| 176 | + let mut findings = orchestrator |
| 177 | + .run(&files) |
| 178 | + .context("orchestrator run failed")?; |
| 179 | + findings.retain(|finding| args.severity_filter.admits(finding.severity)); |
| 180 | + |
| 181 | + render(&findings, args.format, args.no_color)?; |
| 182 | + |
| 183 | + if findings.is_empty() { |
| 184 | + Ok(ExitCode::from(EXIT_CLEAN)) |
| 185 | + } else { |
| 186 | + Ok(ExitCode::from(EXIT_FINDINGS)) |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +fn render(findings: &[Finding], format: OutputFormat, no_color: bool) -> anyhow::Result<()> { |
| 191 | + let resolved = resolve_format(format); |
| 192 | + let stdout = std::io::stdout(); |
| 193 | + let mut handle = stdout.lock(); |
| 194 | + match resolved { |
| 195 | + OutputFormat::Json | OutputFormat::Auto => { |
| 196 | + let mut renderer = JsonRenderer::new(&mut handle); |
| 197 | + renderer.render(findings).context("rendering JSON output")?; |
| 198 | + } |
| 199 | + OutputFormat::Sarif => { |
| 200 | + let mut renderer = SarifRenderer::new(&mut handle, env!("CARGO_PKG_VERSION")); |
| 201 | + renderer |
| 202 | + .render(findings) |
| 203 | + .context("rendering SARIF output")?; |
| 204 | + } |
| 205 | + OutputFormat::Tty => { |
| 206 | + let color = !no_color && std::io::stdout().is_terminal(); |
| 207 | + let mut renderer = TtyRenderer::new(&mut handle).with_color(color); |
| 208 | + renderer.render(findings).context("rendering TTY output")?; |
| 209 | + } |
| 210 | + } |
| 211 | + handle.flush().context("flushing stdout")?; |
| 212 | + Ok(()) |
| 213 | +} |
| 214 | + |
| 215 | +fn resolve_format(format: OutputFormat) -> OutputFormat { |
| 216 | + if format == OutputFormat::Auto { |
| 217 | + if std::io::stdout().is_terminal() { |
| 218 | + OutputFormat::Tty |
| 219 | + } else { |
| 220 | + OutputFormat::Json |
| 221 | + } |
| 222 | + } else { |
| 223 | + format |
| 224 | + } |
| 225 | +} |
0 commit comments