Skip to main content

xtask/rmcp_release_monitor/
impact.rs

1//! Static "does this repo reference the changed upstream terms" scan, shared by
2//! the MCP schema and conformance watches.
3//!
4//! Both watches answer the same question after detecting drift: which files in
5//! *this* repo mention identifiers that moved upstream. The identifier
6//! extraction, the stop list that keeps the shortlist signal-bearing, the
7//! filesystem walk, and the Markdown table that renders the result are one
8//! concern with two callers, so they live here rather than in either watch.
9
10use anyhow::Result;
11use std::collections::BTreeSet;
12use std::fs;
13use std::path::Path;
14use walkdir::WalkDir;
15
16#[derive(Debug)]
17pub(super) struct RepoImpact {
18    path: String,
19    identifiers: Vec<String>,
20}
21
22pub(super) fn append_impact_section(body: &mut String, title: &str, impacts: &[RepoImpact]) {
23    body.push_str(&format!("### {title}\n\n"));
24    body.push_str("_Static identifier matches from upstream changes. Treat this as an inspection shortlist, not a complete migration plan._\n\n");
25    if impacts.is_empty() {
26        body.push_str("No direct local references to changed upstream terms were found.\n\n");
27        return;
28    }
29    body.push_str("| Local file | Changed upstream terms referenced |\n");
30    body.push_str("|---|---|\n");
31    for impact in impacts.iter().take(25) {
32        let terms = impact
33            .identifiers
34            .iter()
35            .take(8)
36            .map(|term| format!("`{term}`"))
37            .collect::<Vec<_>>()
38            .join(", ");
39        body.push_str(&format!("| `{}` | {} |\n", impact.path, terms));
40    }
41    if impacts.len() > 25 {
42        body.push_str(&format!("| _{} more files_ |  |\n", impacts.len() - 25));
43    }
44    body.push('\n');
45}
46
47pub(super) fn collect_identifiers(text: &str, terms: &mut BTreeSet<String>) {
48    let mut current = String::new();
49    for ch in text.chars() {
50        if ch == '_' || ch == '-' || ch.is_ascii_alphanumeric() {
51            current.push(ch);
52        } else {
53            push_identifier(&current, terms);
54            current.clear();
55        }
56    }
57    push_identifier(&current, terms);
58}
59
60fn push_identifier(identifier: &str, terms: &mut BTreeSet<String>) {
61    let trimmed = identifier.trim_matches(|ch: char| ch == '_' || ch == '-');
62    if trimmed.len() < 3 || trimmed.chars().all(|ch| ch.is_ascii_digit()) {
63        return;
64    }
65    let normalized = trimmed.replace('-', "_");
66    if is_stop_identifier(&normalized) {
67        return;
68    }
69    terms.insert(normalized);
70}
71
72fn is_stop_identifier(identifier: &str) -> bool {
73    matches!(
74        identifier,
75        "add"
76            | "all"
77            | "and"
78            | "any"
79            | "api"
80            | "are"
81            | "arr"
82            | "auth"
83            | "body"
84            | "bool"
85            | "const"
86            | "default"
87            | "derive"
88            | "else"
89            | "enum"
90            | "export"
91            | "false"
92            | "for"
93            | "from"
94            | "get"
95            | "impl"
96            | "interface"
97            | "let"
98            | "main"
99            | "mod"
100            | "new"
101            | "not"
102            | "null"
103            | "number"
104            | "object"
105            | "one"
106            | "option"
107            | "pub"
108            | "ref"
109            | "self"
110            | "serde"
111            | "some"
112            | "string"
113            | "test"
114            | "this"
115            | "true"
116            | "type"
117            | "undefined"
118            | "use"
119            | "vec"
120            | "with"
121    )
122}
123
124pub(super) fn scan_repo_impacts(root: &Path, terms: &BTreeSet<String>) -> Result<Vec<RepoImpact>> {
125    if terms.is_empty() {
126        return Ok(Vec::new());
127    }
128    let mut impacts = Vec::new();
129    for entry in WalkDir::new(root).follow_links(false).into_iter() {
130        let entry = entry?;
131        let path = entry.path();
132        if entry.file_type().is_dir() {
133            continue;
134        }
135        let relative = path
136            .strip_prefix(root)
137            .unwrap_or(path)
138            .to_string_lossy()
139            .replace('\\', "/");
140        if !is_repo_scan_file(path) || is_skipped_repo_path(&relative) {
141            continue;
142        }
143        let text = match fs::read_to_string(path) {
144            Ok(text) => text,
145            Err(_) => continue,
146        };
147        let matched = terms
148            .iter()
149            .filter(|term| text.contains(term.as_str()))
150            .take(12)
151            .cloned()
152            .collect::<Vec<_>>();
153        if matched.is_empty() {
154            continue;
155        }
156        impacts.push(RepoImpact {
157            path: relative,
158            identifiers: matched,
159        });
160        if impacts.len() >= 40 {
161            break;
162        }
163    }
164    impacts.sort_by(|left, right| {
165        right
166            .identifiers
167            .len()
168            .cmp(&left.identifiers.len())
169            .then_with(|| left.path.cmp(&right.path))
170    });
171    Ok(impacts)
172}
173
174fn is_repo_scan_file(path: &Path) -> bool {
175    matches!(
176        path.extension().and_then(|ext| ext.to_str()),
177        Some("rs" | "toml" | "json" | "yaml" | "yml" | "md" | "mdx" | "ts" | "tsx" | "js" | "jsx")
178    )
179}
180
181fn is_skipped_repo_path(text: &str) -> bool {
182    [
183        ".git/",
184        "target/",
185        "node_modules/",
186        "dist/",
187        ".next/",
188        "docs/references/mcp/schema/",
189    ]
190    .iter()
191    .any(|needle| {
192        text == needle.trim_end_matches('/')
193            || text.starts_with(needle)
194            || text.contains(&format!("/{needle}"))
195    }) || text == "Cargo.lock"
196        || text.ends_with("/Cargo.lock")
197}