Skip to main content

mdx_rust_analysis/
refactor.rs

1//! Conservative refactor planning analysis for Rust modules.
2//!
3//! This module is intentionally plan-only. It summarizes module shape and
4//! likely refactor pressure without producing edits.
5
6use 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}