Skip to main content

spec_drift/analyzers/
examples.rs

1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, Location, RuleId, Severity};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8/// ExamplesAnalyzer — enforces `compile_failure`.
9///
10/// Strategy: shell out to `cargo check --examples --message-format=json`, parse
11/// the JSON message stream, and surface every `compiler-message` with level
12/// `error` that points at a file under `examples/`.
13///
14/// This is deterministic by definition: if cargo says it doesn't compile, the
15/// example is drifting from the library's current API.
16pub struct ExamplesAnalyzer {
17    /// Overrides the project root used to launch `cargo`. Defaults to
18    /// [`ProjectContext::root`] at analyze-time.
19    manifest_override: Option<PathBuf>,
20    /// Injectable executor, so unit tests can avoid spawning cargo.
21    runner: Box<dyn CargoRunner>,
22}
23
24impl Default for ExamplesAnalyzer {
25    fn default() -> Self {
26        Self {
27            manifest_override: None,
28            runner: Box::new(RealCargoRunner),
29        }
30    }
31}
32
33impl ExamplesAnalyzer {
34    /// Construct an analyzer with a custom runner (used by tests).
35    pub fn with_runner(runner: Box<dyn CargoRunner>) -> Self {
36        Self {
37            manifest_override: None,
38            runner,
39        }
40    }
41
42    fn manifest(&self, ctx: &ProjectContext) -> PathBuf {
43        self.manifest_override
44            .clone()
45            .unwrap_or_else(|| ctx.analysis_root.clone())
46    }
47}
48
49impl DriftAnalyzer for ExamplesAnalyzer {
50    fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
51        let manifest = self.manifest(ctx);
52        // Skip the check entirely if the selected package has no examples dir.
53        let examples_dir = manifest.join("examples");
54        if !examples_dir.exists() {
55            return Vec::new();
56        }
57
58        let stdout = match self.runner.check_examples(&manifest) {
59            Ok(s) => s,
60            Err(e) => {
61                eprintln!("spec-drift: cargo check --examples failed to launch: {e}");
62                return Vec::new();
63            }
64        };
65
66        parse_cargo_messages(&stdout, &manifest, &ctx.root)
67    }
68}
69
70/// Abstraction over invoking cargo so tests can inject canned JSON streams.
71pub trait CargoRunner: Send + Sync {
72    fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String>;
73    /// Clippy output for examples. Used by DeprecatedUsageAnalyzer.
74    /// Default implementation returns empty output so runners that only care
75    /// about `cargo check` don't have to stub a second method.
76    fn clippy_examples(&self, _manifest_dir: &std::path::Path) -> std::io::Result<String> {
77        Ok(String::new())
78    }
79}
80
81pub struct RealCargoRunner;
82
83impl CargoRunner for RealCargoRunner {
84    fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String> {
85        let out = Command::new("cargo")
86            .current_dir(manifest_dir)
87            .args(["check", "--examples", "--message-format=json", "--quiet"])
88            .output()?;
89        // cargo returns non-zero on compile error — we still want the stdout stream.
90        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
91    }
92
93    fn clippy_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String> {
94        let out = Command::new("cargo")
95            .current_dir(manifest_dir)
96            .args(["clippy", "--examples", "--message-format=json", "--quiet"])
97            .output()?;
98        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
99    }
100}
101
102#[derive(Debug, Deserialize)]
103#[serde(tag = "reason")]
104enum CargoMessage {
105    #[serde(rename = "compiler-message")]
106    CompilerMessage { message: CompilerMessage },
107    #[serde(other)]
108    Other,
109}
110
111#[derive(Debug, Deserialize)]
112struct CompilerMessage {
113    message: String,
114    level: String,
115    spans: Vec<DiagSpan>,
116}
117
118#[derive(Debug, Deserialize)]
119struct DiagSpan {
120    file_name: String,
121    line_start: u32,
122    is_primary: bool,
123}
124
125fn parse_cargo_messages(stdout: &str, manifest_root: &Path, report_root: &Path) -> Vec<Divergence> {
126    let mut out = Vec::new();
127
128    for line in stdout.lines() {
129        let line = line.trim();
130        if line.is_empty() || !line.starts_with('{') {
131            continue;
132        }
133        let Ok(msg) = serde_json::from_str::<CargoMessage>(line) else {
134            continue;
135        };
136        let CargoMessage::CompilerMessage { message } = msg else {
137            continue;
138        };
139        if message.level != "error" {
140            continue;
141        }
142
143        // Prefer the primary span; fall back to the first span if none flagged primary.
144        let span = message
145            .spans
146            .iter()
147            .find(|s| s.is_primary)
148            .or_else(|| message.spans.first());
149
150        let Some(span) = span else { continue };
151
152        let path = Path::new(&span.file_name);
153        // Only report diagnostics rooted in the selected package's `examples/`
154        // directory, while rendering locations relative to the workspace root.
155        let abs = if path.is_absolute() {
156            path.to_path_buf()
157        } else {
158            manifest_root.join(path)
159        };
160        let package_rel = abs.strip_prefix(manifest_root).unwrap_or(&abs);
161        let Some(first) = package_rel.components().next() else {
162            continue;
163        };
164        if first.as_os_str() != "examples" {
165            continue;
166        }
167        let report_path = abs.strip_prefix(report_root).unwrap_or(&abs);
168
169        out.push(Divergence {
170            rule: RuleId::CompileFailure,
171            severity: Severity::Critical,
172            location: Location::new(report_path.to_path_buf(), span.line_start),
173            stated: format!("`{}` demonstrates the current API", report_path.display()),
174            reality: format!("`cargo check --examples` fails: {}", message.message),
175            risk: "Users copy from broken examples and ship broken code.".to_string(),
176            attribution: None,
177        });
178    }
179
180    out
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::path::Path;
187
188    struct FakeRunner(String);
189    impl CargoRunner for FakeRunner {
190        fn check_examples(&self, _: &Path) -> std::io::Result<String> {
191            Ok(self.0.clone())
192        }
193    }
194
195    #[test]
196    fn flags_compile_error_in_examples_dir() {
197        let tmp = tempfile::tempdir().unwrap();
198        std::fs::create_dir(tmp.path().join("examples")).unwrap();
199        std::fs::write(tmp.path().join("examples/demo.rs"), "// placeholder").unwrap();
200
201        let stdout = r#"{"reason":"compiler-message","message":{"message":"mismatched types","level":"error","spans":[{"file_name":"examples/demo.rs","line_start":7,"is_primary":true}]}}"#;
202
203        let mut ctx = ProjectContext::new(tmp.path());
204        // Analyzer reads from ctx.root so no additional setup needed.
205        let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
206        ctx.root = tmp.path().to_path_buf();
207        let divergences = analyzer.analyze(&ctx);
208
209        assert_eq!(divergences.len(), 1);
210        let d = &divergences[0];
211        assert_eq!(d.rule, RuleId::CompileFailure);
212        assert_eq!(d.severity, Severity::Critical);
213        assert_eq!(d.location.line, 7);
214        assert_eq!(
215            d.location.file,
216            std::path::PathBuf::from("examples/demo.rs")
217        );
218    }
219
220    #[test]
221    fn package_examples_are_reported_relative_to_workspace_root() {
222        let tmp = tempfile::tempdir().unwrap();
223        let member = tmp.path().join("crates").join("api");
224        std::fs::create_dir_all(member.join("examples")).unwrap();
225        std::fs::write(member.join("examples/demo.rs"), "// placeholder").unwrap();
226
227        let stdout = r#"{"reason":"compiler-message","message":{"message":"mismatched types","level":"error","spans":[{"file_name":"examples/demo.rs","line_start":4,"is_primary":true}]}}"#;
228
229        let mut ctx = ProjectContext::new(tmp.path());
230        ctx.analysis_root = member;
231        let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
232        let divergences = analyzer.analyze(&ctx);
233
234        assert_eq!(divergences.len(), 1);
235        assert_eq!(
236            divergences[0].location.file,
237            std::path::PathBuf::from("crates/api/examples/demo.rs")
238        );
239    }
240
241    #[test]
242    fn ignores_errors_outside_examples_dir() {
243        let tmp = tempfile::tempdir().unwrap();
244        std::fs::create_dir(tmp.path().join("examples")).unwrap();
245        let stdout = r#"{"reason":"compiler-message","message":{"message":"oops","level":"error","spans":[{"file_name":"src/lib.rs","line_start":1,"is_primary":true}]}}"#;
246        let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
247        let ctx = ProjectContext::new(tmp.path());
248        assert!(analyzer.analyze(&ctx).is_empty());
249    }
250
251    #[test]
252    fn skips_cleanly_when_no_examples_dir() {
253        let tmp = tempfile::tempdir().unwrap();
254        let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner("SHOULD NOT RUN".into())));
255        let ctx = ProjectContext::new(tmp.path());
256        assert!(analyzer.analyze(&ctx).is_empty());
257    }
258
259    #[test]
260    fn ignores_warning_level_messages() {
261        let tmp = tempfile::tempdir().unwrap();
262        std::fs::create_dir(tmp.path().join("examples")).unwrap();
263        let stdout = r#"{"reason":"compiler-message","message":{"message":"unused import","level":"warning","spans":[{"file_name":"examples/demo.rs","line_start":3,"is_primary":true}]}}"#;
264        let analyzer = ExamplesAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
265        let ctx = ProjectContext::new(tmp.path());
266        assert!(analyzer.analyze(&ctx).is_empty());
267    }
268}