use super::DriftAnalyzer;
use crate::context::ProjectContext;
use crate::domain::{Divergence, Location, RuleId, Severity};
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Command;
pub struct ExamplesAnalyzer {
manifest_override: Option<PathBuf>,
runner: Box<dyn CargoRunner>,
}
impl Default for ExamplesAnalyzer {
fn default() -> Self {
Self {
manifest_override: None,
runner: Box::new(RealCargoRunner),
}
}
}
impl ExamplesAnalyzer {
pub fn with_runner(runner: Box<dyn CargoRunner>) -> Self {
Self {
manifest_override: None,
runner,
}
}
fn manifest(&self, ctx: &ProjectContext) -> PathBuf {
self.manifest_override
.clone()
.unwrap_or_else(|| ctx.root.clone())
}
}
impl DriftAnalyzer for ExamplesAnalyzer {
fn id(&self) -> &'static str {
"examples"
}
fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
let examples_dir = ctx.root.join("examples");
if !examples_dir.exists() {
return Vec::new();
}
let stdout = match self.runner.check_examples(&self.manifest(ctx)) {
Ok(s) => s,
Err(e) => {
eprintln!("spec-drift: cargo check --examples failed to launch: {e}");
return Vec::new();
}
};
parse_cargo_messages(&stdout, &ctx.root)
}
}
pub trait CargoRunner: Send + Sync {
fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String>;
fn clippy_examples(&self, _manifest_dir: &std::path::Path) -> std::io::Result<String> {
Ok(String::new())
}
}
pub struct RealCargoRunner;
impl CargoRunner for RealCargoRunner {
fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String> {
let out = Command::new("cargo")
.current_dir(manifest_dir)
.args([
"check",
"--examples",
"--message-format=json",
"--quiet",
])
.output()?;
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn clippy_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String> {
let out = Command::new("cargo")
.current_dir(manifest_dir)
.args([
"clippy",
"--examples",
"--message-format=json",
"--quiet",
])
.output()?;
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "reason")]
enum CargoMessage {
#[serde(rename = "compiler-message")]
CompilerMessage { message: CompilerMessage },
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct CompilerMessage {
message: String,
level: String,
spans: Vec<DiagSpan>,
}
#[derive(Debug, Deserialize)]
struct DiagSpan {
file_name: String,
line_start: u32,
is_primary: bool,
}
fn parse_cargo_messages(stdout: &str, root: &std::path::Path) -> Vec<Divergence> {
let mut out = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || !line.starts_with('{') {
continue;
}
let Ok(msg) = serde_json::from_str::<CargoMessage>(line) else {
continue;
};
let CargoMessage::CompilerMessage { message } = msg else {
continue;
};
if message.level != "error" {
continue;
}
let span = message
.spans
.iter()
.find(|s| s.is_primary)
.or_else(|| message.spans.first());
let Some(span) = span else { continue };
let path = std::path::Path::new(&span.file_name);
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let rel = abs.strip_prefix(root).unwrap_or(&abs);
let Some(first) = rel.components().next() else {
continue;
};
if first.as_os_str() != "examples" {
continue;
}
out.push(Divergence {
rule: RuleId::CompileFailure,
severity: Severity::Critical,
location: Location::new(rel.to_path_buf(), span.line_start),
stated: format!("`{}` demonstrates the current API", rel.display()),
reality: format!("`cargo check --examples` fails: {}", message.message),
risk: "Users copy from broken examples and ship broken code.".to_string(),
attribution: None,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
struct FakeRunner(String);
impl CargoRunner for FakeRunner {
fn check_examples(&self, _: &Path) -> std::io::Result<String> {
Ok(self.0.clone())
}
}
#[test]
fn flags_compile_error_in_examples_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("examples")).unwrap();
std::fs::write(tmp.path().join("examples/demo.rs"), "// placeholder").unwrap();
let stdout = r#"{"reason":"compiler-message","message":{"message":"mismatched types","level":"error","spans":[{"file_name":"examples/demo.rs","line_start":7,"is_primary":true}]}}"#;
let mut ctx = ProjectContext::new(tmp.path());
let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
ctx.root = tmp.path().to_path_buf();
let divergences = analyzer.analyze(&ctx);
assert_eq!(divergences.len(), 1);
let d = &divergences[0];
assert_eq!(d.rule, RuleId::CompileFailure);
assert_eq!(d.severity, Severity::Critical);
assert_eq!(d.location.line, 7);
assert_eq!(d.location.file, std::path::PathBuf::from("examples/demo.rs"));
}
#[test]
fn ignores_errors_outside_examples_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("examples")).unwrap();
let stdout = r#"{"reason":"compiler-message","message":{"message":"oops","level":"error","spans":[{"file_name":"src/lib.rs","line_start":1,"is_primary":true}]}}"#;
let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
let ctx = ProjectContext::new(tmp.path());
assert!(analyzer.analyze(&ctx).is_empty());
}
#[test]
fn skips_cleanly_when_no_examples_dir() {
let tmp = tempfile::tempdir().unwrap();
let analyzer =
ExamplesAnalyzer::with_runner(Box::new(FakeRunner("SHOULD NOT RUN".into())));
let ctx = ProjectContext::new(tmp.path());
assert!(analyzer.analyze(&ctx).is_empty());
}
#[test]
fn ignores_warning_level_messages() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("examples")).unwrap();
let stdout = r#"{"reason":"compiler-message","message":{"message":"unused import","level":"warning","spans":[{"file_name":"examples/demo.rs","line_start":3,"is_primary":true}]}}"#;
let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
let ctx = ProjectContext::new(tmp.path());
assert!(analyzer.analyze(&ctx).is_empty());
}
}