spec_drift/analyzers/
examples.rs1use 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
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.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 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
70pub trait CargoRunner: Send + Sync {
72 fn check_examples(&self, manifest_dir: &std::path::Path) -> std::io::Result<String>;
73 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 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 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 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 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}