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 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}