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