use std::path::Path;
use std::sync::Arc;
use camel_api::component_metadata::ComponentMetadataCatalog;
use camel_lint::{Diagnostic, LintEngine, Severity};
use clap::Args;
#[derive(Args, Debug)]
pub struct LintArgs {
pub file: String,
}
#[derive(Debug)]
pub struct LintOutcome {
pub diagnostics: Vec<Diagnostic>,
pub source: String,
pub exit_code: i32,
pub cli_error: Option<String>,
}
pub async fn production_engine() -> Result<LintEngine, String> {
let mut ctx = camel_core::CamelContext::builder()
.build()
.await
.map_err(|e| format!("failed to build CamelContext: {e}"))?;
crate::register_builtin_components_for_lint(&mut ctx);
let catalog: Arc<dyn ComponentMetadataCatalog> = Arc::new(ctx.metadata_catalog());
Ok(LintEngine::new(catalog).with_default_rules())
}
pub async fn run_lint(path: &Path) -> LintOutcome {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
return LintOutcome {
diagnostics: Vec::new(),
source: String::new(),
exit_code: 2,
cli_error: Some(format!("failed to read '{}': {e}", path.display())),
};
}
};
let engine = match production_engine().await {
Ok(engine) => engine,
Err(e) => {
return LintOutcome {
diagnostics: Vec::new(),
source,
exit_code: 2,
cli_error: Some(e),
};
}
};
let diagnostics = engine.lint(&source);
let has_error = diagnostics.iter().any(|d| d.severity == Severity::Error);
let exit_code = if has_error { 1 } else { 0 };
LintOutcome {
diagnostics,
source,
exit_code,
cli_error: None,
}
}
pub async fn run(args: LintArgs) {
let outcome = run_lint(Path::new(&args.file)).await;
if let Some(err) = &outcome.cli_error {
eprintln!("error: {err}");
} else {
let file_id = args.file.as_str();
for diag in &outcome.diagnostics {
render_diagnostic(diag, file_id, &outcome.source);
}
}
std::process::exit(outcome.exit_code);
}
fn render_diagnostic(diag: &Diagnostic, file_id: &str, source: &str) {
use ariadne::{Label, Report, ReportKind, Source};
let kind = match diag.severity {
Severity::Error => ReportKind::Error,
Severity::Warning => ReportKind::Warning,
Severity::Info => ReportKind::Advice,
};
let range = clamp_range(diag.span.start, diag.span.end, source.len());
Report::build(kind, file_id, range.start)
.with_code(diag.code.to_string())
.with_message(diag.message.as_str())
.with_label(Label::new((file_id, range)))
.finish()
.eprint((file_id, Source::from(source)))
.ok();
}
fn clamp_range(start: usize, end: usize, len: usize) -> std::ops::Range<usize> {
let s = start.min(len);
let e = end.min(len).max(s);
s..e
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[tokio::test]
async fn lint_clean_route_exits_zero() {
let yaml = "id: r1\nfrom: timer:foo?period=1s\nsteps:\n - to: log:bar\n";
let mut tmp = tempfile::NamedTempFile::new().unwrap(); write!(tmp, "{yaml}").unwrap();
let outcome = run_lint(tmp.path()).await;
assert_eq!(
outcome.exit_code, 0,
"clean route must exit 0; diags = {:?}",
outcome.diagnostics
);
assert!(
outcome.diagnostics.is_empty(),
"clean route must emit no diagnostics; got = {:?}",
outcome.diagnostics
);
}
#[tokio::test]
async fn lint_route_with_error_exits_one() {
let yaml = "id: r1\nfrom: direct:start\nsteps:\n - to: timer:foo?bogusOption=1\n";
let mut tmp = tempfile::NamedTempFile::new().unwrap(); write!(tmp, "{yaml}").unwrap();
let outcome = run_lint(tmp.path()).await;
assert_eq!(outcome.exit_code, 1, "route with an error must exit 1");
assert!(
outcome
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error),
"expected at least one Error diagnostic; got = {:?}",
outcome.diagnostics
);
}
#[tokio::test]
async fn lint_missing_file_exits_two() {
let outcome = run_lint(std::path::Path::new("/nonexistent/route-lint-missing.yaml")).await;
assert_eq!(outcome.exit_code, 2);
assert!(outcome.cli_error.is_some());
assert!(outcome.diagnostics.is_empty());
}
#[test]
fn register_for_lint_does_not_capture_handles() {
let _: fn(&mut camel_core::CamelContext) = crate::register_builtin_components_for_lint;
}
}