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