spec_drift/analyzers/
examples.rs1use 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
8pub struct ExamplesAnalyzer {
17 manifest_override: Option<PathBuf>,
20 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 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 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
73pub trait CargoRunner: Send + Sync {
75 fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String>;
76 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 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 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 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 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}