Skip to main content

camel_cli/commands/
lint.rs

1//! `camel lint <file>` — lint a route file with the production component
2//! catalog.
3//!
4//! Builds a `CamelContext`, registers the builtins via
5//! [`crate::register_builtin_components_for_lint`], obtains the runtime
6//! metadata catalog, and runs all default lint rules over the file. Diagnostics
7//! are rendered with ariadne to stderr. Exit codes: 0 clean, 1 any
8//! error-severity diagnostic, 2 CLI misuse (missing/unreadable file).
9
10use std::path::Path;
11use std::sync::Arc;
12
13use camel_api::component_metadata::ComponentMetadataCatalog;
14use camel_lint::{Diagnostic, LintEngine, Severity};
15use clap::Args;
16
17/// CLI args for `camel lint`.
18#[derive(Args, Debug)]
19pub struct LintArgs {
20    /// Path to the route file (YAML or JSON) to lint.
21    pub file: String,
22}
23
24/// Outcome of a lint run.
25#[derive(Debug)]
26pub struct LintOutcome {
27    /// Diagnostics emitted by the engine (empty on CLI misuse).
28    pub diagnostics: Vec<Diagnostic>,
29    /// Source text read from the file (empty on CLI misuse). Kept so the
30    /// caller can render diagnostics with byte-exact spans.
31    pub source: String,
32    /// Exit code the CLI would emit: 0 clean, 1 any Error diagnostic, 2 CLI
33    /// misuse (missing/unreadable file).
34    pub exit_code: i32,
35    /// CLI-misuse message printed to stderr when `exit_code == 2`.
36    pub cli_error: Option<String>,
37}
38
39/// Build the production lint engine with the full builtin component catalog.
40///
41/// This is the **single source of truth** for the engine construction sequence
42/// that `camel lint` uses at runtime. The corpus zero-false-positives gate and
43/// the production-catalog smoke test call this so their path is structurally
44/// identical to the CLI path — no copy-paste drift possible.
45pub async fn production_engine() -> Result<LintEngine, String> {
46    let mut ctx = camel_core::CamelContext::builder()
47        .build()
48        .await
49        .map_err(|e| format!("failed to build CamelContext: {e}"))?;
50    crate::register_builtin_components_for_lint(&mut ctx);
51    let catalog: Arc<dyn ComponentMetadataCatalog> = Arc::new(ctx.metadata_catalog());
52    Ok(LintEngine::new(catalog).with_default_rules())
53}
54
55/// Run the lint engine over `path` with the production component catalog.
56///
57/// Both the [`run`] CLI entrypoint and the in-process tests call this; the
58/// returned [`LintOutcome`] carries the exit code the CLI would emit so tests
59/// can assert on it without spawning a subprocess.
60pub async fn run_lint(path: &Path) -> LintOutcome {
61    let source = match std::fs::read_to_string(path) {
62        Ok(s) => s,
63        Err(e) => {
64            return LintOutcome {
65                diagnostics: Vec::new(),
66                source: String::new(),
67                exit_code: 2,
68                cli_error: Some(format!("failed to read '{}': {e}", path.display())),
69            };
70        }
71    };
72
73    let engine = match production_engine().await {
74        Ok(engine) => engine,
75        Err(e) => {
76            return LintOutcome {
77                diagnostics: Vec::new(),
78                source,
79                exit_code: 2,
80                cli_error: Some(e),
81            };
82        }
83    };
84    let diagnostics = engine.lint(&source);
85
86    let has_error = diagnostics.iter().any(|d| d.severity == Severity::Error);
87    let exit_code = if has_error { 1 } else { 0 };
88
89    LintOutcome {
90        diagnostics,
91        source,
92        exit_code,
93        cli_error: None,
94    }
95}
96
97/// CLI entrypoint for `camel lint`.
98pub async fn run(args: LintArgs) {
99    let outcome = run_lint(Path::new(&args.file)).await;
100    if let Some(err) = &outcome.cli_error {
101        eprintln!("error: {err}");
102    } else {
103        let file_id = args.file.as_str();
104        for diag in &outcome.diagnostics {
105            render_diagnostic(diag, file_id, &outcome.source);
106        }
107    }
108    std::process::exit(outcome.exit_code);
109}
110
111/// Render a single diagnostic to stderr with ariadne.
112fn render_diagnostic(diag: &Diagnostic, file_id: &str, source: &str) {
113    use ariadne::{Label, Report, ReportKind, Source};
114
115    let kind = match diag.severity {
116        Severity::Error => ReportKind::Error,
117        Severity::Warning => ReportKind::Warning,
118        Severity::Info => ReportKind::Advice,
119    };
120    let range = clamp_range(diag.span.start, diag.span.end, source.len());
121
122    Report::build(kind, file_id, range.start)
123        .with_code(diag.code.to_string())
124        .with_message(diag.message.as_str())
125        .with_label(Label::new((file_id, range)))
126        .finish()
127        .eprint((file_id, Source::from(source)))
128        .ok();
129    // Render failure is non-fatal: the diagnostic still counts toward the
130    // exit code. ariadne rarely fails post-clamp; logging at warn would
131    // require a tracing subscriber that lint (short-lived) may not install.
132}
133
134/// Map a diagnostic's byte span onto a valid ariadne range, clamping to the
135/// source length and ensuring `end >= start`.
136fn clamp_range(start: usize, end: usize, len: usize) -> std::ops::Range<usize> {
137    let s = start.min(len);
138    let e = end.min(len).max(s);
139    s..e
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::io::Write;
146
147    #[tokio::test]
148    async fn lint_clean_route_exits_zero() {
149        let yaml = "id: r1\nfrom: timer:foo?period=1s\nsteps:\n  - to: log:bar\n";
150        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
151        write!(tmp, "{yaml}").unwrap(); // allow-unwrap
152
153        let outcome = run_lint(tmp.path()).await;
154        assert_eq!(
155            outcome.exit_code, 0,
156            "clean route must exit 0; diags = {:?}",
157            outcome.diagnostics
158        );
159        assert!(
160            outcome.diagnostics.is_empty(),
161            "clean route must emit no diagnostics; got = {:?}",
162            outcome.diagnostics
163        );
164    }
165
166    #[tokio::test]
167    async fn lint_route_with_error_exits_one() {
168        let yaml = "id: r1\nfrom: direct:start\nsteps:\n  - to: timer:foo?bogusOption=1\n";
169        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
170        write!(tmp, "{yaml}").unwrap(); // allow-unwrap
171
172        let outcome = run_lint(tmp.path()).await;
173        assert_eq!(outcome.exit_code, 1, "route with an error must exit 1");
174        assert!(
175            outcome
176                .diagnostics
177                .iter()
178                .any(|d| d.severity == Severity::Error),
179            "expected at least one Error diagnostic; got = {:?}",
180            outcome.diagnostics
181        );
182    }
183
184    #[tokio::test]
185    async fn lint_missing_file_exits_two() {
186        let outcome = run_lint(std::path::Path::new("/nonexistent/route-lint-missing.yaml")).await;
187        assert_eq!(outcome.exit_code, 2);
188        assert!(outcome.cli_error.is_some());
189        assert!(outcome.diagnostics.is_empty());
190    }
191
192    #[test]
193    fn register_for_lint_does_not_capture_handles() {
194        // Compile-time signature check: the function takes &mut CamelContext
195        // and returns () — no bridge/pool/datasource/path handle is captured
196        // or returned.
197        let _: fn(&mut camel_core::CamelContext) = crate::register_builtin_components_for_lint;
198    }
199}