Skip to main content

spec_drift/analyzers/
deprecated.rs

1use 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
8/// DeprecatedUsageAnalyzer — enforces `deprecated_usage`.
9///
10/// Strategy: run `cargo clippy --examples --message-format=json` and surface
11/// every warning whose diagnostic code is `deprecated`. This re-frames the
12/// built-in lint as drift: an example that still calls deprecated API is
13/// teaching users a pattern the codebase is moving away from.
14pub 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    // rustc: `deprecated`; clippy: `clippy::deprecated_*`, etc.
147    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}