use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use super::super::{CodewikiInput, DeprecatedSymbol, DeprecationIndex, DeprecationsDoc, TestIndex};
use crate::models::Symbol;
const LOOKBACK_LINES: usize = 12;
const REASON_MAX: usize = 160;
pub(crate) struct AuditContext {
pub(crate) deprecations: DeprecationIndex,
pub(crate) tests: TestIndex,
}
pub(crate) fn build_audit_context(project_root: &Path, input: &CodewikiInput) -> AuditContext {
AuditContext {
deprecations: build_deprecation_index(project_root, input),
tests: build_test_index(project_root, input),
}
}
fn build_test_index(project_root: &Path, input: &CodewikiInput) -> TestIndex {
let mut index: TestIndex = BTreeSet::new();
let mut file_cache: BTreeMap<&str, Option<Vec<String>>> = BTreeMap::new();
for symbol in &input.symbols {
if is_test_gated(symbol, project_root, &mut file_cache) {
index.insert(symbol.id.clone());
}
}
index
}
fn build_deprecation_index(project_root: &Path, input: &CodewikiInput) -> DeprecationIndex {
let mut index: DeprecationIndex = BTreeMap::new();
let mut symbols_by_file: BTreeMap<&str, Vec<&Symbol>> = BTreeMap::new();
for symbol in &input.symbols {
symbols_by_file
.entry(symbol.file_path.as_str())
.or_default()
.push(symbol);
}
for (file, symbols) in symbols_by_file {
let Ok(contents) = std::fs::read_to_string(project_root.join(file)) else {
continue;
};
let lines = contents.lines().collect::<Vec<_>>();
for symbol in symbols {
if let Some(reason) = deprecation_reason(&lines, symbol) {
index.insert(symbol.id.clone(), reason);
}
}
}
index
}
fn deprecation_reason(lines: &[&str], symbol: &Symbol) -> Option<String> {
let lookback = lookback_lines(lines, symbol.line_start);
let attr_reason = deprecated_attribute_reason(&lookback);
if let Some(reason) = attr_reason {
return Some(reason);
}
if let Some(reason) = doc_comment_deprecation(&lookback) {
return Some(reason);
}
if let Some(docstring) = &symbol.docstring
&& docstring.to_ascii_uppercase().contains("DEPRECATED")
{
return Some(cap_reason(first_meaningful_line(docstring)));
}
None
}
fn lookback_lines<'a>(lines: &[&'a str], line_start: usize) -> Vec<&'a str> {
let mut collected: Vec<&str> = Vec::new();
let mut idx = line_start.saturating_sub(2);
let mut steps = 0;
loop {
if steps >= LOOKBACK_LINES || line_start < 2 {
break;
}
let Some(line) = lines.get(idx) else {
break;
};
let trimmed = line.trim_start();
let is_attr_or_doc = trimmed.starts_with('#')
|| trimmed.starts_with("///")
|| trimmed.starts_with("//!")
|| trimmed.starts_with("//")
|| trimmed.starts_with('"')
|| trimmed.starts_with(')')
|| trimmed.starts_with('@');
if trimmed.is_empty() {
break;
}
collected.push(line);
if !is_attr_or_doc && !looks_like_attribute_continuation(trimmed) {
break;
}
if idx == 0 {
break;
}
idx -= 1;
steps += 1;
}
collected.reverse();
collected
}
fn looks_like_attribute_continuation(trimmed: &str) -> bool {
trimmed.contains("note")
|| trimmed.contains("since")
|| trimmed.starts_with(')')
|| trimmed.ends_with(',')
|| trimmed.ends_with(')')
}
fn deprecated_attribute_reason(lookback: &[&str]) -> Option<String> {
let joined = lookback.join(" ");
if !joined.contains("#[deprecated") && !joined.contains("# [deprecated") {
return None;
}
if let Some(note) = extract_note(&joined) {
return Some(cap_reason(note));
}
Some("deprecated".to_string())
}
fn extract_note(joined: &str) -> Option<String> {
let note_at = joined.find("note")?;
let after = &joined[note_at..];
let eq = after.find('=')?;
let rest = &after[eq + 1..];
let open = rest.find('"')?;
let tail = &rest[open + 1..];
let close = tail.find('"')?;
let note = tail[..close].trim();
if note.is_empty() {
None
} else {
Some(note.to_string())
}
}
fn doc_comment_deprecation(lookback: &[&str]) -> Option<String> {
for line in lookback {
let trimmed = line.trim_start();
let is_doc = trimmed.starts_with("///") || trimmed.starts_with("//!");
if is_doc && trimmed.to_ascii_uppercase().contains("DEPRECATED") {
let text = trimmed
.trim_start_matches("///")
.trim_start_matches("//!")
.trim();
return Some(cap_reason(text.to_string()));
}
}
None
}
fn first_meaningful_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
.to_string()
}
fn cap_reason(mut reason: String) -> String {
if reason.chars().count() > REASON_MAX {
reason = reason.chars().take(REASON_MAX).collect::<String>();
reason.push('…');
}
reason
}
pub(crate) fn build_deprecations_doc(
input: &CodewikiInput,
deprecations: &DeprecationIndex,
) -> DeprecationsDoc {
let mut symbols = input
.symbols
.iter()
.filter_map(|symbol| {
deprecations.get(&symbol.id).map(|reason| DeprecatedSymbol {
file: symbol.file_path.clone(),
name: symbol.name.clone(),
kind: symbol.kind.clone(),
line: symbol.line_start,
reason: reason.clone(),
})
})
.collect::<Vec<_>>();
symbols.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.line.cmp(&b.line))
.then(a.name.cmp(&b.name))
});
DeprecationsDoc {
symbols,
degraded_sources: Vec::new(),
}
}
fn is_test_gated<'a>(
symbol: &'a Symbol,
project_root: &Path,
file_cache: &mut BTreeMap<&'a str, Option<Vec<String>>>,
) -> bool {
if is_test_path(&symbol.file_path) {
return true;
}
let lines = file_cache
.entry(symbol.file_path.as_str())
.or_insert_with(|| {
std::fs::read_to_string(project_root.join(&symbol.file_path))
.ok()
.map(|contents| contents.lines().map(str::to_string).collect::<Vec<_>>())
});
let Some(lines) = lines else {
return false;
};
let refs = lines.iter().map(String::as_str).collect::<Vec<_>>();
let lookback = lookback_lines(&refs, symbol.line_start);
lookback.iter().any(|line| {
let trimmed = line.trim_start();
trimmed.contains("#[test]")
|| trimmed.contains("#[cfg(test)]")
|| trimmed.contains("#[tokio::test")
})
}
fn is_test_path(file: &str) -> bool {
file.split('/')
.any(|segment| matches!(segment, "tests" | "test" | "tests.rs" | "test.rs"))
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(src: &str) -> Vec<&str> {
src.lines().collect()
}
#[test]
fn lookback_stops_at_blank_line() {
let src = "fn far() {}\n\n#[deprecated]\nfn target() {}\n";
let back = lookback_lines(&lines(src), 4);
assert!(back.iter().any(|l| l.contains("#[deprecated]")));
assert!(!back.iter().any(|l| l.contains("fn far")));
}
#[test]
fn extracts_note_from_attribute() {
assert_eq!(
extract_note(r#"#[deprecated(note = "use new_fn")]"#).as_deref(),
Some("use new_fn")
);
}
#[test]
fn bare_deprecated_attribute_reason_is_deprecated() {
let back = ["#[deprecated]"];
assert_eq!(
deprecated_attribute_reason(&back).as_deref(),
Some("deprecated")
);
}
#[test]
fn doc_comment_deprecated_is_detected_case_insensitively() {
let back = ["/// deprecated: prefer the new API"];
let reason = doc_comment_deprecation(&back).expect("doc deprecation");
assert!(reason.to_ascii_lowercase().contains("prefer the new api"));
}
#[test]
fn test_path_requires_whole_segment_or_test_file_suffix() {
assert!(is_test_path("crates/foo/tests/api.rs"));
assert!(is_test_path("crates/foo/src/tests.rs"));
assert!(is_test_path("crates/foo/src/test.rs"));
assert!(!is_test_path("crates/foo/contest/api.rs"));
assert!(!is_test_path("crates/foo/src/latest.rs"));
}
}