1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11pub struct RefactorAnalysis {
12 pub root: PathBuf,
13 pub target: Option<PathBuf>,
14 pub files_scanned: usize,
15 pub files: Vec<RefactorFileSummary>,
16 pub module_edges: Vec<ModuleEdge>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20pub struct RefactorFileSummary {
21 pub file: PathBuf,
22 pub line_count: usize,
23 pub function_count: usize,
24 pub public_item_count: usize,
25 pub largest_function_lines: usize,
26 pub has_tests: bool,
27 pub public_items: Vec<PublicItemSummary>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
31pub struct PublicItemSummary {
32 pub kind: String,
33 pub name: String,
34 pub line: usize,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
38pub struct ModuleEdge {
39 pub from: PathBuf,
40 pub to: String,
41 pub line: usize,
42 pub kind: ModuleEdgeKind,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
46pub enum ModuleEdgeKind {
47 ModDeclaration,
48 CrateUse,
49 SuperUse,
50}
51
52#[derive(Debug, Clone, Copy)]
53pub struct RefactorAnalyzeConfig<'a> {
54 pub target: Option<&'a Path>,
55 pub max_files: usize,
56}
57
58pub fn analyze_refactor(
59 root: &Path,
60 config: RefactorAnalyzeConfig<'_>,
61) -> anyhow::Result<RefactorAnalysis> {
62 let files = collect_rust_files(root, config.target)?;
63 let mut summaries = Vec::new();
64 let mut module_edges = Vec::new();
65
66 for file in files.iter().take(config.max_files) {
67 let content = std::fs::read_to_string(file)?;
68 let rel = relative_path(root, file);
69 module_edges.extend(find_module_edges(&rel, &content));
70 summaries.push(summarize_file(&rel, &content));
71 }
72
73 Ok(RefactorAnalysis {
74 root: root.to_path_buf(),
75 target: config.target.map(Path::to_path_buf),
76 files_scanned: summaries.len(),
77 files: summaries,
78 module_edges,
79 })
80}
81
82fn summarize_file(file: &Path, content: &str) -> RefactorFileSummary {
83 let line_count = content.lines().count();
84 let has_tests = content.contains("#[cfg(test)]") || content.contains("#[test]");
85 let function_ranges = find_function_ranges(content);
86 let largest_function_lines = function_ranges
87 .iter()
88 .map(|range| range.line_count)
89 .max()
90 .unwrap_or(0);
91 let public_items = public_items(content);
92
93 RefactorFileSummary {
94 file: file.to_path_buf(),
95 line_count,
96 function_count: function_ranges.len(),
97 public_item_count: public_items.len(),
98 largest_function_lines,
99 has_tests,
100 public_items,
101 }
102}
103
104fn public_items(content: &str) -> Vec<PublicItemSummary> {
105 let parsed = match syn::parse_file(content) {
106 Ok(parsed) => parsed,
107 Err(_) => return Vec::new(),
108 };
109
110 parsed
111 .items
112 .iter()
113 .filter_map(|item| public_item_summary(item, content))
114 .collect()
115}
116
117fn public_item_summary(item: &syn::Item, content: &str) -> Option<PublicItemSummary> {
118 let (kind, name, token) = match item {
119 syn::Item::Const(item) if is_public(&item.vis) => (
120 "const",
121 item.ident.to_string(),
122 format!("const {}", item.ident),
123 ),
124 syn::Item::Enum(item) if is_public(&item.vis) => (
125 "enum",
126 item.ident.to_string(),
127 format!("enum {}", item.ident),
128 ),
129 syn::Item::Fn(item) if is_public(&item.vis) => (
130 "fn",
131 item.sig.ident.to_string(),
132 format!("fn {}", item.sig.ident),
133 ),
134 syn::Item::Struct(item) if is_public(&item.vis) => (
135 "struct",
136 item.ident.to_string(),
137 format!("struct {}", item.ident),
138 ),
139 syn::Item::Trait(item) if is_public(&item.vis) => (
140 "trait",
141 item.ident.to_string(),
142 format!("trait {}", item.ident),
143 ),
144 syn::Item::Type(item) if is_public(&item.vis) => (
145 "type",
146 item.ident.to_string(),
147 format!("type {}", item.ident),
148 ),
149 syn::Item::Mod(item) if is_public(&item.vis) => {
150 ("mod", item.ident.to_string(), format!("mod {}", item.ident))
151 }
152 _ => return None,
153 };
154
155 Some(PublicItemSummary {
156 kind: kind.to_string(),
157 name,
158 line: line_for_token(content, &token),
159 })
160}
161
162fn is_public(vis: &syn::Visibility) -> bool {
163 matches!(vis, syn::Visibility::Public(_))
164}
165
166#[derive(Debug)]
167struct FunctionRange {
168 line_count: usize,
169}
170
171fn find_function_ranges(content: &str) -> Vec<FunctionRange> {
172 let lines: Vec<&str> = content.lines().collect();
173 let mut ranges = Vec::new();
174 let mut index = 0;
175
176 while index < lines.len() {
177 let trimmed = lines[index].trim_start();
178 let is_fn = trimmed.starts_with("fn ")
179 || trimmed.starts_with("pub fn ")
180 || trimmed.starts_with("pub(crate) fn ")
181 || trimmed.starts_with("pub(super) fn ")
182 || trimmed.starts_with("async fn ")
183 || trimmed.starts_with("pub async fn ");
184 if !is_fn {
185 index += 1;
186 continue;
187 }
188
189 let mut brace_depth: isize = 0;
190 let mut saw_open = false;
191 let mut end_index = index;
192 for (offset, line) in lines[index..].iter().enumerate() {
193 let code = line_without_strings(line);
194 brace_depth += code.matches('{').count() as isize;
195 if code.contains('{') {
196 saw_open = true;
197 }
198 brace_depth -= code.matches('}').count() as isize;
199 end_index = index + offset;
200 if saw_open && brace_depth <= 0 {
201 break;
202 }
203 }
204
205 ranges.push(FunctionRange {
206 line_count: end_index.saturating_sub(index) + 1,
207 });
208 index = end_index.saturating_add(1);
209 }
210
211 ranges
212}
213
214fn find_module_edges(file: &Path, content: &str) -> Vec<ModuleEdge> {
215 let mut edges = Vec::new();
216 for (index, line) in content.lines().enumerate() {
217 let line_no = index + 1;
218 let trimmed = line.trim_start();
219 if let Some(rest) = module_declaration_rest(trimmed) {
220 let module = rest
221 .trim_end_matches(';')
222 .split_whitespace()
223 .next()
224 .unwrap_or_default();
225 if !module.is_empty() {
226 edges.push(ModuleEdge {
227 from: file.to_path_buf(),
228 to: module.to_string(),
229 line: line_no,
230 kind: ModuleEdgeKind::ModDeclaration,
231 });
232 }
233 }
234
235 if let Some(rest) = trimmed.strip_prefix("use crate::") {
236 edges.push(ModuleEdge {
237 from: file.to_path_buf(),
238 to: rest.trim_end_matches(';').to_string(),
239 line: line_no,
240 kind: ModuleEdgeKind::CrateUse,
241 });
242 } else if let Some(rest) = trimmed.strip_prefix("use super::") {
243 edges.push(ModuleEdge {
244 from: file.to_path_buf(),
245 to: rest.trim_end_matches(';').to_string(),
246 line: line_no,
247 kind: ModuleEdgeKind::SuperUse,
248 });
249 }
250 }
251 edges
252}
253
254fn module_declaration_rest(trimmed: &str) -> Option<&str> {
255 trimmed
256 .strip_prefix("mod ")
257 .or_else(|| trimmed.strip_prefix("pub mod "))
258 .or_else(|| trimmed.strip_prefix("pub(crate) mod "))
259 .or_else(|| trimmed.strip_prefix("pub(super) mod "))
260}
261
262fn line_for_token(content: &str, token: &str) -> usize {
263 content
264 .lines()
265 .position(|line| line.contains(token))
266 .map(|index| index + 1)
267 .unwrap_or(1)
268}
269
270fn line_without_strings(line: &str) -> String {
271 let mut output = String::with_capacity(line.len());
272 let mut in_string = false;
273 let mut escaped = false;
274 for ch in line.chars() {
275 if in_string {
276 if escaped {
277 escaped = false;
278 } else if ch == '\\' {
279 escaped = true;
280 } else if ch == '"' {
281 in_string = false;
282 }
283 output.push(' ');
284 } else if ch == '"' {
285 in_string = true;
286 output.push(' ');
287 } else {
288 output.push(ch);
289 }
290 }
291 output
292}
293
294fn collect_rust_files(root: &Path, target: Option<&Path>) -> anyhow::Result<Vec<PathBuf>> {
295 let scan_root = target
296 .map(|path| {
297 if path.is_absolute() {
298 path.to_path_buf()
299 } else {
300 root.join(path)
301 }
302 })
303 .unwrap_or_else(|| root.to_path_buf());
304 if !scan_root.starts_with(root) {
305 anyhow::bail!("refactor target is outside root: {}", scan_root.display());
306 }
307
308 if scan_root.is_file() {
309 return Ok(if scan_root.extension().is_some_and(|ext| ext == "rs") {
310 vec![scan_root]
311 } else {
312 Vec::new()
313 });
314 }
315
316 let mut files = Vec::new();
317 for result in ignore::WalkBuilder::new(scan_root)
318 .hidden(false)
319 .filter_entry(|entry| {
320 let name = entry.file_name().to_string_lossy();
321 !matches!(
322 name.as_ref(),
323 "target" | ".git" | ".worktrees" | ".mdx-rust"
324 )
325 })
326 .build()
327 {
328 let entry = result?;
329 let path = entry.path();
330 if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
331 files.push(path.to_path_buf());
332 }
333 }
334 files.sort();
335 Ok(files)
336}
337
338fn relative_path(root: &Path, path: &Path) -> PathBuf {
339 path.strip_prefix(root).unwrap_or(path).to_path_buf()
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use tempfile::tempdir;
346
347 #[test]
348 fn refactor_analysis_summarizes_public_api_and_modules() {
349 let dir = tempdir().unwrap();
350 let src = dir.path().join("src");
351 std::fs::create_dir_all(&src).unwrap();
352 std::fs::write(
353 src.join("lib.rs"),
354 r#"pub mod api;
355use crate::api::Handler;
356
357pub struct Config {
358 value: String,
359}
360
361pub fn load() -> anyhow::Result<String> {
362 Ok(String::new())
363}
364"#,
365 )
366 .unwrap();
367
368 let analysis = analyze_refactor(
369 dir.path(),
370 RefactorAnalyzeConfig {
371 target: Some(Path::new("src/lib.rs")),
372 max_files: 10,
373 },
374 )
375 .unwrap();
376
377 assert_eq!(analysis.files_scanned, 1);
378 assert_eq!(analysis.files[0].public_item_count, 3);
379 assert!(analysis.files[0]
380 .public_items
381 .iter()
382 .any(|item| item.name == "load"));
383 assert_eq!(analysis.module_edges.len(), 2);
384 }
385}