xtask/rmcp_release_monitor/
impact.rs1use 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(¤t, terms);
54 current.clear();
55 }
56 }
57 push_identifier(¤t, 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}