Skip to main content

split_modules/
lib.rs

1//! `split-modules`: split large Rust source files into one-item-per-file submodules,
2//! preserving comments and the public API, with the compiler as the safety net.
3//!
4//! See [`split_file`] and [`split_project`] for the entry points.
5
6pub mod apply;
7pub mod classify;
8pub mod model;
9pub mod pathfix;
10pub mod plan;
11pub mod project;
12pub mod util;
13pub mod verify;
14
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18
19use crate::apply::{format_files, write_plan};
20use crate::model::{Config, FileOutcome};
21use crate::plan::build_plan;
22use crate::verify::{cargo_check, error_excerpt, find_manifest_dir};
23
24/// Shared per-crate context resolved once (manifest dir, edition, baseline build state).
25pub struct CrateCtx {
26    pub manifest_dir: Option<PathBuf>,
27    pub edition: String,
28    /// Whether the crate compiled before we touched it (verification is meaningless if not).
29    pub baseline_ok: Option<bool>,
30}
31
32impl CrateCtx {
33    /// Resolve context for a path. Runs a baseline `cargo check` only when `verify`.
34    pub fn resolve(path: &Path, verify: bool) -> CrateCtx {
35        let manifest_dir = find_manifest_dir(path);
36        let edition = manifest_dir
37            .as_deref()
38            .and_then(detect_edition)
39            .unwrap_or_else(|| "2021".to_string());
40        let baseline_ok = if verify {
41            manifest_dir.as_deref().map(|d| cargo_check(d, false).ok)
42        } else {
43            None
44        };
45        CrateCtx { manifest_dir, edition, baseline_ok }
46    }
47}
48
49/// Read `edition = "20xx"` from a crate manifest (best effort, no TOML dependency).
50pub fn detect_edition(manifest_dir: &Path) -> Option<String> {
51    let text = std::fs::read_to_string(manifest_dir.join("Cargo.toml")).ok()?;
52    for line in text.lines() {
53        let t = line.trim();
54        if let Some(rest) = t.strip_prefix("edition") {
55            if let Some(eq) = rest.find('=') {
56                let val = rest[eq + 1..].trim().trim_matches(['"', '\'']);
57                if !val.is_empty() {
58                    return Some(val.to_string());
59                }
60            }
61        }
62    }
63    None
64}
65
66/// Split a single file, using the supplied crate context.
67pub fn split_file_with(path: &Path, config: &Config, ctx: &CrateCtx) -> Result<FileOutcome> {
68    let src = std::fs::read_to_string(path)
69        .with_context(|| format!("reading {}", path.display()))?;
70
71    let plan = build_plan(path, &src)?;
72    if plan.is_noop() {
73        return Ok(FileOutcome::Skipped(format!(
74            "only {} module file(s) would result; nothing to split",
75            plan.files.len()
76        )));
77    }
78
79    if config.dry_run {
80        return Ok(FileOutcome::Split {
81            files: plan.files.iter().map(|f| plan.out_dir.join(format!("{}.rs", f.stem))).collect(),
82        });
83    }
84
85    let applied = write_plan(&plan, config)?;
86
87    if config.rustfmt {
88        format_files(&applied.files, &ctx.edition);
89    }
90
91    let want_verify = config.verify && ctx.baseline_ok == Some(true) && ctx.manifest_dir.is_some();
92    if want_verify {
93        let dir = ctx.manifest_dir.as_deref().unwrap();
94        let post = cargo_check(dir, false);
95        if !post.ok {
96            applied.rollback()?;
97            return Ok(FileOutcome::RolledBack(error_excerpt(&post.stderr)));
98        }
99    }
100
101    Ok(FileOutcome::Split { files: applied.files })
102}
103
104/// Split a single file (resolves its own crate context).
105pub fn split_file(path: &Path, config: &Config) -> Result<FileOutcome> {
106    let ctx = CrateCtx::resolve(path, config.verify);
107    split_file_with(path, config, &ctx)
108}
109
110/// Split every eligible file under `root` (a file or directory).
111pub fn split_project(root: &Path, config: &Config) -> Result<Vec<(PathBuf, FileOutcome)>> {
112    let candidates = project::find_candidates(root, config)?;
113    let ctx = CrateCtx::resolve(root, config.verify);
114    let mut results = Vec::new();
115    for path in candidates {
116        let outcome = split_file_with(&path, config, &ctx)?;
117        results.push((path, outcome));
118    }
119    Ok(results)
120}