spec_drift/analyzers/
deprecated.rs1use super::DriftAnalyzer;
2use super::examples::{CargoRunner, RealCargoRunner};
3use crate::context::ProjectContext;
4use crate::domain::{Divergence, Location, RuleId, Severity};
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8pub struct DeprecatedUsageAnalyzer {
15 runner: Box<dyn CargoRunner>,
16}
17
18impl Default for DeprecatedUsageAnalyzer {
19 fn default() -> Self {
20 Self {
21 runner: Box::new(RealCargoRunner),
22 }
23 }
24}
25
26impl DeprecatedUsageAnalyzer {
27 pub fn with_runner(runner: Box<dyn CargoRunner>) -> Self {
28 Self { runner }
29 }
30}
31
32impl DriftAnalyzer for DeprecatedUsageAnalyzer {
33 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
34 let manifest = &ctx.analysis_root;
35 let examples_dir = manifest.join("examples");
36 if !examples_dir.exists() {
37 return Vec::new();
38 }
39
40 let stdout = match self.runner.clippy_examples(manifest) {
41 Ok(s) => s,
42 Err(e) => {
43 eprintln!("spec-drift: cargo clippy --examples failed to launch: {e}");
44 return Vec::new();
45 }
46 };
47
48 parse_clippy_messages(&stdout, manifest, &ctx.root)
49 }
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(tag = "reason")]
54enum ClippyMessage {
55 #[serde(rename = "compiler-message")]
56 CompilerMessage { message: CompilerMessage },
57 #[serde(other)]
58 Other,
59}
60
61#[derive(Debug, Deserialize)]
62struct CompilerMessage {
63 message: String,
64 level: String,
65 code: Option<DiagCode>,
66 spans: Vec<DiagSpan>,
67}
68
69#[derive(Debug, Deserialize)]
70struct DiagCode {
71 code: String,
72}
73
74#[derive(Debug, Deserialize)]
75struct DiagSpan {
76 file_name: String,
77 line_start: u32,
78 is_primary: bool,
79}
80
81fn parse_clippy_messages(
82 stdout: &str,
83 manifest_root: &Path,
84 report_root: &Path,
85) -> Vec<Divergence> {
86 let mut out = Vec::new();
87
88 for line in stdout.lines() {
89 let line = line.trim();
90 if line.is_empty() || !line.starts_with('{') {
91 continue;
92 }
93 let Ok(msg) = serde_json::from_str::<ClippyMessage>(line) else {
94 continue;
95 };
96 let ClippyMessage::CompilerMessage { message } = msg else {
97 continue;
98 };
99 if message.level != "warning" {
100 continue;
101 }
102 let Some(code) = message.code.as_ref() else {
103 continue;
104 };
105 if !is_deprecated_code(&code.code) {
106 continue;
107 }
108
109 let span = message
110 .spans
111 .iter()
112 .find(|s| s.is_primary)
113 .or_else(|| message.spans.first());
114 let Some(span) = span else { continue };
115
116 let path = Path::new(&span.file_name);
117 let abs: PathBuf = if path.is_absolute() {
118 path.to_path_buf()
119 } else {
120 manifest_root.join(path)
121 };
122 let package_rel = abs.strip_prefix(manifest_root).unwrap_or(&abs);
123 let Some(first) = package_rel.components().next() else {
124 continue;
125 };
126 if first.as_os_str() != "examples" {
127 continue;
128 }
129 let report_path = abs.strip_prefix(report_root).unwrap_or(&abs);
130
131 out.push(Divergence {
132 rule: RuleId::DeprecatedUsage,
133 severity: Severity::Warning,
134 location: Location::new(report_path.to_path_buf(), span.line_start),
135 stated: format!("`{}` demonstrates supported API", report_path.display()),
136 reality: format!("example uses deprecated API: {}", message.message),
137 risk: "Examples teach users a pattern the codebase is retiring.".to_string(),
138 attribution: None,
139 });
140 }
141
142 out
143}
144
145fn is_deprecated_code(code: &str) -> bool {
146 code == "deprecated" || code.starts_with("deprecated") || code.contains("::deprecated")
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::path::Path;
154
155 struct FakeRunner(String);
156 impl CargoRunner for FakeRunner {
157 fn check_examples(&self, _: &Path) -> std::io::Result<String> {
158 Ok(String::new())
159 }
160 fn clippy_examples(&self, _: &Path) -> std::io::Result<String> {
161 Ok(self.0.clone())
162 }
163 }
164
165 #[test]
166 fn flags_deprecated_warning_in_example() {
167 let tmp = tempfile::tempdir().unwrap();
168 std::fs::create_dir(tmp.path().join("examples")).unwrap();
169
170 let stdout = r#"{"reason":"compiler-message","message":{"message":"use of deprecated function","level":"warning","code":{"code":"deprecated"},"spans":[{"file_name":"examples/demo.rs","line_start":12,"is_primary":true}]}}"#;
171
172 let ctx = ProjectContext::new(tmp.path());
173 let analyzer =
174 DeprecatedUsageAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
175 let divs = analyzer.analyze(&ctx);
176
177 assert_eq!(divs.len(), 1);
178 assert_eq!(divs[0].rule, RuleId::DeprecatedUsage);
179 assert_eq!(divs[0].severity, Severity::Warning);
180 assert_eq!(divs[0].location.line, 12);
181 }
182
183 #[test]
184 fn package_examples_are_reported_relative_to_workspace_root() {
185 let tmp = tempfile::tempdir().unwrap();
186 let member = tmp.path().join("crates").join("api");
187 std::fs::create_dir_all(member.join("examples")).unwrap();
188
189 let stdout = r#"{"reason":"compiler-message","message":{"message":"use of deprecated function","level":"warning","code":{"code":"deprecated"},"spans":[{"file_name":"examples/demo.rs","line_start":9,"is_primary":true}]}}"#;
190
191 let mut ctx = ProjectContext::new(tmp.path());
192 ctx.analysis_root = member;
193 let analyzer =
194 DeprecatedUsageAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
195 let divs = analyzer.analyze(&ctx);
196
197 assert_eq!(divs.len(), 1);
198 assert_eq!(
199 divs[0].location.file,
200 std::path::PathBuf::from("crates/api/examples/demo.rs")
201 );
202 }
203
204 #[test]
205 fn ignores_non_deprecated_warnings() {
206 let tmp = tempfile::tempdir().unwrap();
207 std::fs::create_dir(tmp.path().join("examples")).unwrap();
208 let stdout = r#"{"reason":"compiler-message","message":{"message":"unused import","level":"warning","code":{"code":"unused_imports"},"spans":[{"file_name":"examples/demo.rs","line_start":3,"is_primary":true}]}}"#;
209 let ctx = ProjectContext::new(tmp.path());
210 let analyzer =
211 DeprecatedUsageAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
212 assert!(analyzer.analyze(&ctx).is_empty());
213 }
214
215 #[test]
216 fn ignores_deprecated_outside_examples_dir() {
217 let tmp = tempfile::tempdir().unwrap();
218 std::fs::create_dir(tmp.path().join("examples")).unwrap();
219 let stdout = r#"{"reason":"compiler-message","message":{"message":"deprecated","level":"warning","code":{"code":"deprecated"},"spans":[{"file_name":"src/lib.rs","line_start":1,"is_primary":true}]}}"#;
220 let ctx = ProjectContext::new(tmp.path());
221 let analyzer =
222 DeprecatedUsageAnalyzer::with_runner(Box::new(FakeRunner(stdout.to_string())));
223 assert!(analyzer.analyze(&ctx).is_empty());
224 }
225
226 #[test]
227 fn noop_when_no_examples_dir() {
228 let tmp = tempfile::tempdir().unwrap();
229 let analyzer = DeprecatedUsageAnalyzer::with_runner(Box::new(FakeRunner(
230 "SHOULD NOT PARSE".to_string(),
231 )));
232 let ctx = ProjectContext::new(tmp.path());
233 assert!(analyzer.analyze(&ctx).is_empty());
234 }
235}