use std::path::Path;
use std::sync::Arc;
use camel_api::component_metadata::ComponentMetadataCatalog;
use camel_lint::{Diagnostic, DiagnosticCode, 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 cli_info: 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 fn is_stagec_exempt_path(path: &Path) -> bool {
let comps: Vec<_> = path.components().collect();
comps
.windows(2)
.any(|w| w[0].as_os_str() == "tests" && w[1].as_os_str() == "fixtures")
}
pub async fn run_lint(path: &Path) -> LintOutcome {
if camel_dsl::discovery::is_test_document(path) {
return LintOutcome {
diagnostics: Vec::new(),
source: String::new(),
exit_code: 0,
cli_error: None,
cli_info: Some(format!(
"skipped: {} is a camel test document",
path.display()
)),
};
}
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())),
cli_info: None,
};
}
};
let engine = match production_engine().await {
Ok(engine) => engine,
Err(e) => {
return LintOutcome {
diagnostics: Vec::new(),
source,
exit_code: 2,
cli_error: Some(e),
cli_info: None,
};
}
};
let diagnostics = engine.lint(&source);
let diagnostics = if is_stagec_exempt_path(path) {
diagnostics
.into_iter()
.filter(|d| d.code != DiagnosticCode::RMock)
.collect()
} else {
diagnostics
};
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,
cli_info: 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 if let Some(info) = &outcome.cli_info {
println!("{info}");
} 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());
}
#[tokio::test]
async fn lint_skips_test_document_with_info() {
let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("demo.test.yaml");
std::fs::write(&path, "routeFiles: [x]\nexpects: {}\n").unwrap();
let outcome = run_lint(&path).await;
assert_eq!(
outcome.exit_code, 0,
"test document must be skipped with exit 0"
);
assert!(
outcome.diagnostics.is_empty(),
"test document must emit no diagnostics; got = {:?}",
outcome.diagnostics
);
let info = outcome.cli_info.expect("skip must set cli_info"); assert!(
info.contains("camel test document"),
"info must say why the file was skipped; got = {info}"
);
}
#[tokio::test]
async fn lint_routes_normal_yaml_unchanged() {
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);
assert!(
outcome.cli_info.is_none(),
"normal route must not be skipped"
);
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;
}
}