Skip to main content

rscheck_cli/
lib.rs

1pub mod analysis;
2mod cargo_clippy;
3pub mod config;
4mod config_file;
5pub mod emit;
6pub mod fix;
7mod fix_apply;
8mod path_pattern;
9pub mod policy;
10pub mod report;
11mod report_html;
12mod report_sarif;
13pub mod rules;
14pub mod runner;
15pub mod semantic;
16pub mod span;
17#[cfg(test)]
18pub(crate) mod test_support;
19mod text_report;
20mod toolchain;
21
22use crate::analysis::Workspace;
23use crate::config::{OutputFormat, Policy, ToolchainMode};
24use crate::report::{AdapterRun, Report};
25use crate::runner::Runner;
26use cargo_clippy::run_clippy;
27use clap::Parser;
28use config_file::{default_config_path, load_from, workspace_root, write_default_config};
29use fix_apply::{ApplyError, PlannedEdits, apply_planned_edits, plan_edits, print_dry_run};
30use std::path::{Path, PathBuf};
31use std::process::ExitCode as ProcessExitCode;
32use std::{fs, io};
33use text_report::render_text_report;
34use toolchain::{ResolvedToolchain, resolve_toolchain};
35
36#[derive(Clone, Copy)]
37#[repr(transparent)]
38pub struct ExitCode(pub i32);
39
40impl From<i32> for ExitCode {
41    fn from(value: i32) -> Self {
42        Self(value)
43    }
44}
45
46impl std::process::Termination for ExitCode {
47    fn report(self) -> ProcessExitCode {
48        ProcessExitCode::from(self.0 as u8)
49    }
50}
51
52#[derive(Debug, Parser)]
53#[command(name = "rscheck", version)]
54pub struct Cli {
55    #[command(subcommand)]
56    pub cmd: Command,
57}
58
59#[derive(Debug, clap::Subcommand)]
60pub enum Command {
61    Check(CheckArgs),
62    ListRules,
63    Explain { rule_id: String },
64    Init(InitArgs),
65}
66
67#[derive(Debug, clap::Args)]
68pub struct CommonOutputArgs {
69    #[arg(long)]
70    pub config: Option<PathBuf>,
71
72    #[arg(long, value_enum)]
73    pub format: Option<FormatArg>,
74
75    #[arg(long)]
76    pub output: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, Copy, clap::ValueEnum)]
80pub enum FormatArg {
81    Text,
82    Json,
83    Sarif,
84    Html,
85}
86
87impl From<FormatArg> for OutputFormat {
88    fn from(value: FormatArg) -> Self {
89        match value {
90            FormatArg::Text => OutputFormat::Text,
91            FormatArg::Json => OutputFormat::Json,
92            FormatArg::Sarif => OutputFormat::Sarif,
93            FormatArg::Html => OutputFormat::Html,
94        }
95    }
96}
97
98#[derive(Debug, clap::Args)]
99pub struct CheckArgs {
100    #[command(flatten)]
101    pub out: CommonOutputArgs,
102
103    #[arg(long, default_value_t = true)]
104    pub rscheck: bool,
105
106    #[arg(long)]
107    pub write: bool,
108
109    #[arg(long = "unsafe")]
110    pub unsafe_fixes: bool,
111
112    #[arg(long)]
113    pub dry_run: bool,
114
115    #[arg(long, default_value_t = 10)]
116    pub max_fix_iterations: u32,
117
118    #[arg(long, value_enum)]
119    pub toolchain: Option<ToolchainArg>,
120
121    #[arg(trailing_var_arg = true)]
122    pub cargo_args: Vec<String>,
123}
124
125#[derive(Debug, Clone, Copy, clap::ValueEnum)]
126pub enum ToolchainArg {
127    Current,
128    Auto,
129    Nightly,
130}
131
132impl From<ToolchainArg> for ToolchainMode {
133    fn from(value: ToolchainArg) -> Self {
134        match value {
135            ToolchainArg::Current => ToolchainMode::Current,
136            ToolchainArg::Auto => ToolchainMode::Auto,
137            ToolchainArg::Nightly => ToolchainMode::Nightly,
138        }
139    }
140}
141
142#[derive(Debug, clap::Args)]
143pub struct InitArgs {
144    #[arg(long)]
145    pub path: Option<PathBuf>,
146}
147
148pub fn main_entry() -> ExitCode {
149    init_tracing();
150    let cli = Cli::parse();
151    match cli.cmd {
152        Command::Check(args) => run_check(args),
153        Command::ListRules => run_list_rules(),
154        Command::Explain { rule_id } => run_explain(&rule_id),
155        Command::Init(args) => run_init(args),
156    }
157}
158
159fn init_tracing() {
160    let _ = tracing_subscriber::fmt()
161        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
162        .try_init();
163}
164
165fn run_list_rules() -> ExitCode {
166    for info in rules::rule_catalog() {
167        println!(
168            "{}\t{:?}\t{:?}\t{:?}\t{}",
169            info.id, info.family, info.backend, info.default_level, info.summary
170        );
171    }
172    ExitCode::from(0)
173}
174
175fn run_explain(rule_id: &str) -> ExitCode {
176    let info = rules::rule_catalog().into_iter().find(|i| i.id == rule_id);
177    match info {
178        Some(info) => {
179            println!(
180                "{}\n\nfamily: {:?}\nbackend: {:?}\ndefault: {:?}\nschema: {}\n\n{}\n",
181                info.id,
182                info.family,
183                info.backend,
184                info.default_level,
185                info.schema,
186                info.config_example
187            );
188            ExitCode::from(0)
189        }
190        None => {
191            eprintln!("unknown rule: {rule_id}");
192            ExitCode::from(2)
193        }
194    }
195}
196
197fn run_init(args: InitArgs) -> ExitCode {
198    let root = match workspace_root() {
199        Ok(p) => p,
200        Err(err) => {
201            eprintln!("{err}");
202            return ExitCode::from(2);
203        }
204    };
205
206    let path = args.path.unwrap_or_else(|| default_config_path(&root));
207    if path.exists() {
208        eprintln!("config already exists: {}", path.to_string_lossy());
209        return ExitCode::from(1);
210    }
211
212    if let Err(err) = write_default_config(&path) {
213        eprintln!("failed to write config: {err}");
214        return ExitCode::from(2);
215    }
216
217    println!("{}", path.to_string_lossy());
218    ExitCode::from(0)
219}
220
221fn run_check(args: CheckArgs) -> ExitCode {
222    if let Err(code) = validate_check_args(&args) {
223        return code;
224    }
225
226    let root = match resolve_workspace_root() {
227        Ok(root) => root,
228        Err(code) => return code,
229    };
230    let policy = match load_check_policy(&args, &root) {
231        Ok(policy) => policy,
232        Err(code) => return code,
233    };
234    let resolved_toolchain = match resolve_toolchain(&policy, args.toolchain.map(Into::into)) {
235        Ok(toolchain) => toolchain,
236        Err(err) => return toolchain_error_to_exit_code(err),
237    };
238
239    execute_check_iterations(args, root, policy, resolved_toolchain)
240}
241
242fn validate_check_args(args: &CheckArgs) -> Result<(), ExitCode> {
243    if args.write && args.dry_run {
244        eprintln!("`--write` and `--dry-run` are mutually exclusive");
245        return Err(ExitCode::from(2));
246    }
247    Ok(())
248}
249
250fn resolve_workspace_root() -> Result<PathBuf, ExitCode> {
251    workspace_root().map_err(|err| {
252        eprintln!("{err}");
253        ExitCode::from(2)
254    })
255}
256
257fn load_check_policy(args: &CheckArgs, root: &Path) -> Result<Policy, ExitCode> {
258    let config_path = args
259        .out
260        .config
261        .clone()
262        .unwrap_or_else(|| default_config_path(root));
263    let mut policy = if config_path.exists() {
264        load_from(&config_path).map_err(|err| {
265            eprintln!("{err}");
266            ExitCode::from(2)
267        })?
268    } else {
269        Policy::default_with_rules(rules::default_rule_settings())
270    };
271    if let Some(format) = args.out.format {
272        policy.output.format = format.into();
273    }
274    if let Some(output) = args.out.output.clone() {
275        policy.output.output = Some(output);
276    }
277    Ok(policy)
278}
279
280fn execute_check_iterations(
281    args: CheckArgs,
282    root: PathBuf,
283    policy: Policy,
284    resolved_toolchain: ResolvedToolchain,
285) -> ExitCode {
286    let iterations = iteration_count(&args);
287    let mut last_report = Report::default();
288
289    for is_last in (0..iterations).map(|iter| iter + 1 == iterations) {
290        let ws = match load_workspace(root.clone(), &policy) {
291            Ok(ws) => ws,
292            Err(code) => return code,
293        };
294        let report = match build_iteration_report(&args, &policy, &resolved_toolchain, &ws) {
295            Ok(report) => report,
296            Err(code) => return code,
297        };
298        let action = match handle_iteration(&args, &policy, &report) {
299            Ok(action) => action,
300            Err(code) => return code,
301        };
302        match action {
303            IterationAction::Return(code) => return code,
304            IterationAction::ContinueWithReport(report) => {
305                last_report = *report;
306                if is_last {
307                    break;
308                }
309            }
310        }
311    }
312
313    finish_write_mode(&last_report, &policy)
314}
315
316fn iteration_count(args: &CheckArgs) -> u32 {
317    if args.write || args.dry_run {
318        args.max_fix_iterations.max(1)
319    } else {
320        1
321    }
322}
323
324enum IterationAction {
325    Return(ExitCode),
326    ContinueWithReport(Box<Report>),
327}
328
329fn handle_iteration(
330    args: &CheckArgs,
331    policy: &Policy,
332    report: &Report,
333) -> Result<IterationAction, ExitCode> {
334    let planned = plan_edits(report, args.unsafe_fixes);
335    if args.dry_run {
336        return dry_run_result(policy, report, &planned).map(IterationAction::Return);
337    }
338    if !args.write {
339        return write_and_return(policy, report).map(IterationAction::Return);
340    }
341    apply_write_iteration(report, &planned)
342        .map(Box::new)
343        .map(IterationAction::ContinueWithReport)
344}
345
346fn dry_run_result(
347    policy: &Policy,
348    report: &Report,
349    planned: &PlannedEdits,
350) -> Result<ExitCode, ExitCode> {
351    let would_change = print_dry_run(planned).map_err(io_error_to_exit_code)?;
352    write_report(report, policy).map_err(output_error_to_exit_code)?;
353    Ok(ExitCode::from(if would_change { 1 } else { 0 }))
354}
355
356fn write_and_return(policy: &Policy, report: &Report) -> Result<ExitCode, ExitCode> {
357    write_report(report, policy).map_err(output_error_to_exit_code)?;
358    Ok(ExitCode::from(report.worst_severity().exit_code()))
359}
360
361fn apply_write_iteration(report: &Report, planned: &PlannedEdits) -> Result<Report, ExitCode> {
362    if planned.is_empty() {
363        return Ok(report.clone());
364    }
365    let _applied = apply_planned_edits(planned).map_err(io_error_to_exit_code)?;
366    Ok(report.clone())
367}
368
369fn finish_write_mode(report: &Report, policy: &Policy) -> ExitCode {
370    if let Err(err) = write_report(report, policy) {
371        return output_error_to_exit_code(err);
372    }
373    ExitCode::from(report.worst_severity().exit_code())
374}
375
376fn load_workspace(root: PathBuf, policy: &Policy) -> Result<Workspace, ExitCode> {
377    Workspace::new(root).load_files(policy).map_err(|err| {
378        eprintln!("{err}");
379        ExitCode::from(2)
380    })
381}
382
383fn build_iteration_report(
384    args: &CheckArgs,
385    policy: &Policy,
386    resolved_toolchain: &ResolvedToolchain,
387    ws: &Workspace,
388) -> Result<Report, ExitCode> {
389    let mut report = run_rscheck_engine(args.rscheck, policy, resolved_toolchain, ws)?;
390    run_clippy_adapter(
391        &mut report,
392        policy,
393        resolved_toolchain,
394        ws,
395        &args.cargo_args,
396    )?;
397    report.summary.toolchain = Some(resolved_toolchain.summary());
398    Ok(report)
399}
400
401fn run_rscheck_engine(
402    enabled: bool,
403    policy: &Policy,
404    resolved_toolchain: &ResolvedToolchain,
405    ws: &Workspace,
406) -> Result<Report, ExitCode> {
407    if !enabled {
408        return Ok(Report::default());
409    }
410
411    Runner::run_with_semantic_status(ws, policy, resolved_toolchain.semantic_status()).map_err(
412        |err| {
413            eprintln!("{err}");
414            ExitCode::from(2)
415        },
416    )
417}
418
419fn run_clippy_adapter(
420    report: &mut Report,
421    policy: &Policy,
422    resolved_toolchain: &ResolvedToolchain,
423    ws: &Workspace,
424    cargo_args: &[String],
425) -> Result<(), ExitCode> {
426    if !policy.adapters.clippy.enabled {
427        return Ok(());
428    }
429
430    ensure_clippy_adapter_run(report);
431    let toolchain = resolved_toolchain
432        .clippy_selector(policy.adapters.clippy.toolchain)
433        .map_err(toolchain_error_to_exit_code)?;
434    let runtime = resolved_toolchain
435        .clippy_runtime_label(policy.adapters.clippy.toolchain)
436        .map_err(toolchain_error_to_exit_code)?;
437    let mut clippy_args = policy.adapters.clippy.args.clone();
438    clippy_args.extend(cargo_args.iter().cloned());
439    let mut findings = run_clippy(&ws.root, toolchain, &clippy_args).map_err(|err| {
440        eprintln!("{err}");
441        ExitCode::from(2)
442    })?;
443    report.findings.append(&mut findings);
444    set_clippy_adapter_status(report, runtime);
445    Ok(())
446}
447
448fn ensure_clippy_adapter_run(report: &mut Report) {
449    if report
450        .summary
451        .adapter_runs
452        .iter()
453        .any(|run| run.name == "clippy")
454    {
455        return;
456    }
457    report.summary.adapter_runs.push(AdapterRun {
458        name: "clippy".to_string(),
459        enabled: true,
460        toolchain: None,
461        status: None,
462    });
463}
464
465fn set_clippy_adapter_status(report: &mut Report, runtime: String) {
466    if let Some(adapter_run) = report
467        .summary
468        .adapter_runs
469        .iter_mut()
470        .find(|run| run.name == "clippy")
471    {
472        adapter_run.toolchain = Some(runtime);
473        adapter_run.status = Some("ok".to_string());
474    }
475}
476
477fn toolchain_error_to_exit_code(err: toolchain::ToolchainError) -> ExitCode {
478    eprintln!("{err}");
479    ExitCode::from(2)
480}
481
482fn io_error_to_exit_code(err: ApplyError) -> ExitCode {
483    eprintln!("{err}");
484    ExitCode::from(2)
485}
486
487fn output_error_to_exit_code(err: OutputError) -> ExitCode {
488    eprintln!("{err}");
489    ExitCode::from(2)
490}
491
492#[derive(Debug, thiserror::Error)]
493pub enum OutputError {
494    #[error("failed to serialize report")]
495    Serialize(#[source] serde_json::Error),
496    #[error("failed to write output")]
497    Write(#[source] io::Error),
498}
499
500fn write_report(report: &Report, policy: &Policy) -> Result<(), OutputError> {
501    let text = match policy.output.format {
502        OutputFormat::Text => render_text_report(report),
503        OutputFormat::Json => {
504            serde_json::to_string_pretty(report).map_err(OutputError::Serialize)?
505        }
506        OutputFormat::Sarif => serde_json::to_string_pretty(&report_sarif::to_sarif(report))
507            .map_err(OutputError::Serialize)?,
508        OutputFormat::Html => report_html::to_html(report),
509    };
510
511    match &policy.output.output {
512        Some(path) => fs::write(path, text).map_err(OutputError::Write),
513        None => {
514            print!("{text}");
515            Ok(())
516        }
517    }
518}