Skip to main content

rust_split/
plan.rs

1//! Tier 2 step 1 — the cohesion clusterer.
2//!
3//! This is **not** a bin-packer. The objective is to put **related items
4//! together and unrelated items apart**. The number of output files is a
5//! *consequence* of how related the code is, never something to minimise: if a
6//! file genuinely contains ten unrelated 50-line items, ten 50-line files is the
7//! correct answer, and merging any of them to "save a file" or "fill a bin"
8//! makes the split worse. `max_loc` is only a **ceiling**, never a packing
9//! target: a transitively related group bigger than **half** the ceiling is
10//! partitioned into roughly equal cohesive parts (strongest reference edges
11//! bond first, so the cuts fall on the weakest edges), leaning toward *more*
12//! files rather than files that graze the ceiling.
13//!
14//! Section banners bound responsibilities: references never join different
15//! sections. Inside a section, relation is a sibling reference (the manifest's
16//! `adjacency_hint`). Local types with inherent impls stay in the parent;
17//! their shared ownership does not bond otherwise independent impl blocks.
18//! Clustering is greedy agglomerative on those reference edges, heaviest edge
19//! first, capped at the component's balanced share; leftover clusters of the
20//! same component are then consolidated in source order up to the same share.
21//! Items with no edges stay alone. Imports/preamble are pulled out as a shared
22//! header (the reassembler copies into each module only the imports it
23//! references); an item that is itself `>= max_loc` is reported oversized.
24//!
25//! These chunk classes never enter clustering:
26//! - local structs, enums and unions with inherent impls stay in the parent,
27//!   preserving private fields for the implementation modules below them;
28//! - owner-local impls and lexical macro dependencies/consumers stay in their
29//!   original scope and source order rather than changing restricted visibility
30//!   or textual macro resolution;
31//! - visibility-qualified `use`/`extern crate` (`pub use ...`) are **re-exports**
32//!   — the file's API surface — and stay at the root verbatim (`root_items`);
33//! - content-less `mod name;` declarations bind files relative to the root's
34//!   directory and stay at the root verbatim (`root_items`);
35//! - body-carrying `mod name { ... }` items are already module boundaries and
36//!   are handled separately (`mods`). Unconditional modules are extracted;
37//!   conditional modules retain their braced scopes inline.
38
39use crate::{Exploded, ManifestRow, naming};
40use std::collections::{BTreeMap, BTreeSet, HashMap};
41
42/// The result of planning a split.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SplitPlan {
45    /// Chunk indices forming the shared header (preamble + plain imports).
46    pub header: Vec<usize>,
47    /// Chunk indices kept at the root verbatim, in source order: re-exports
48    /// (`pub use ...`, `pub(crate) use ...`, visibility-qualified
49    /// `extern crate`), content-less `mod name;` declarations, and local types
50    /// with inherent impls, owner-local impls and required lexical macro
51    /// components. Dropping or demoting a `pub use` would narrow the public API.
52    pub root_items: Vec<usize>,
53    /// Chunk indices of body-carrying `mod name { ... }` items, in source
54    /// order. Never packed into a cluster: unconditional modules are extracted
55    /// whole; conditional modules retain their original braced boundaries.
56    /// A module pinned by a lexical macro dependency is instead a root item.
57    pub mods: Vec<usize>,
58    /// Cohesive destination modules, in source order.
59    pub parts: Vec<Part>,
60    /// Items that cannot be placed under the ceiling on their own.
61    pub oversized: Vec<Oversized>,
62}
63
64/// One cohesive module: a cluster of related items.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Part {
67    /// A content-derived module name: the title of the first section banner
68    /// heading one of its items, else the cluster's dominant item. Never a
69    /// crate name such as `core` or a keyword.
70    pub name: String,
71    /// Chunk indices packed here, in source order.
72    pub chunk_indices: Vec<usize>,
73    /// Total LOC; always `< max_loc`.
74    pub loc: usize,
75}
76
77/// An item whose own LOC is at or above the ceiling.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Oversized {
80    pub chunk_index: usize,
81    pub name: String,
82    pub kind: String,
83    pub loc: usize,
84    /// True for a `mod` (recoverable by a nested split); false for a leaf.
85    /// Body-carrying mods are now planned via `mods` (any size), so an
86    /// oversized entry is a leaf and this is false unless the mod chunk failed
87    /// to re-parse.
88    pub recoverable: bool,
89}
90
91impl SplitPlan {
92    pub fn is_complete(&self) -> bool {
93        self.oversized.is_empty()
94    }
95}
96
97struct Item {
98    chunk_index: usize,
99    name: String,
100    kind: String,
101    loc: usize,
102    refs: Vec<String>,
103    /// Title of the section banner in the comments leading this item.
104    title: Option<String>,
105    /// Source section, starting at each explicit banner (even a repeated title).
106    section: usize,
107}
108
109impl Item {
110    fn new(row: &ManifestRow, chunk_text: &str, section: usize) -> Self {
111        // The chunk is the item's span plus the comments leading it.
112        let lead = row.span.start.saturating_sub(row.byte_range.start);
113        Self {
114            chunk_index: row.chunk_index,
115            name: row.name.clone(),
116            kind: row.kind.clone(),
117            loc: row.loc,
118            refs: row.adjacency_hint.clone(),
119            title: chunk_text.get(..lead).and_then(naming::banner_title),
120            section,
121        }
122    }
123}
124
125/// Cluster `exploded`'s items into cohesive modules, each `< max_loc`.
126pub fn plan_split(exploded: &Exploded, max_loc: usize) -> SplitPlan {
127    let mut header = Vec::new();
128    let mut root_items = Vec::new();
129    let mut mods = Vec::new();
130    let mut oversized = Vec::new();
131    let mut items: Vec<Item> = Vec::new();
132    let owned_types = inherent_type_names(exploded);
133    let lexical_items = lexical_root_items(exploded, &owned_types);
134    let mut section = 0;
135
136    for row in &exploded.manifest.rows {
137        let chunk_text = exploded.chunks[row.chunk_index].text.as_str();
138        let lead = row.span.start.saturating_sub(row.byte_range.start);
139        if chunk_text
140            .get(..lead)
141            .and_then(naming::banner_title)
142            .is_some()
143        {
144            section = row.chunk_index + 1;
145        }
146        match row.kind.as_str() {
147            _ if lexical_items.contains(&row.chunk_index) => root_items.push(row.chunk_index),
148            "preamble" => header.push(row.chunk_index),
149            "use" | "extern_crate" => {
150                if is_reexport(chunk_text) {
151                    root_items.push(row.chunk_index);
152                } else {
153                    header.push(row.chunk_index);
154                }
155            }
156            "mod" => match mod_body(chunk_text) {
157                Some(true) => mods.push(row.chunk_index),
158                Some(false) => root_items.push(row.chunk_index),
159                // Chunk did not re-parse: fall back to clustering it intact.
160                None => items.push(Item::new(row, chunk_text, section)),
161            },
162            _ if row.loc >= max_loc => oversized.push(Oversized {
163                chunk_index: row.chunk_index,
164                name: row.name.clone(),
165                kind: row.kind.clone(),
166                loc: row.loc,
167                recoverable: row.kind == "mod",
168            }),
169            "struct" | "enum" | "union" if owned_types.contains(&row.name) => {
170                root_items.push(row.chunk_index);
171            }
172            "impl" if has_owner_local_members(chunk_text) => root_items.push(row.chunk_index),
173            _ => items.push(Item::new(row, chunk_text, section)),
174        }
175    }
176
177    // An unparseable mod chunk that is itself over the ceiling still needs
178    // reporting rather than forcing.
179    items.retain(|item| {
180        if item.loc >= max_loc {
181            oversized.push(Oversized {
182                chunk_index: item.chunk_index,
183                name: item.name.clone(),
184                kind: item.kind.clone(),
185                loc: item.loc,
186                recoverable: item.kind == "mod",
187            });
188            false
189        } else {
190            true
191        }
192    });
193    oversized.sort_by_key(|over| over.chunk_index);
194
195    let parts = if items.is_empty() {
196        Vec::new()
197    } else {
198        cluster(&items, max_loc)
199    };
200    SplitPlan {
201        header,
202        root_items,
203        mods,
204        parts,
205        oversized,
206    }
207}
208
209/// Explicit owner-local members cannot move unchanged into a child. Keep the
210/// impl at its original scope instead of rewriting its visibility restriction.
211fn has_owner_local_members(text: &str) -> bool {
212    let Ok(syn::Item::Impl(item)) = syn::parse_str(text) else {
213        return false;
214    };
215    item.trait_.is_none()
216        && item.items.iter().any(|member| {
217            let vis = match member {
218                syn::ImplItem::Fn(item) => &item.vis,
219                syn::ImplItem::Const(item) => &item.vis,
220                syn::ImplItem::Type(item) => &item.vis,
221                _ => return false,
222            };
223            matches!(vis, syn::Visibility::Restricted(vis) if vis.path.is_ident("self"))
224        })
225}
226
227/// Local macro_rules dependencies require textual scope, not ordinary globs.
228/// Starting from retained types/owner-local impls, keep needed definitions and all
229/// syntactic consumers at the parent in source order. Follow macro-to-macro
230/// edges in both directions: moving a consumer below a later redefinition
231/// would otherwise silently change which definition it expands. Identifier
232/// matching is deliberately conservative; no expansion or macro grammar is
233/// inferred. Each macro name/consumer edge is processed at most once.
234fn lexical_root_items(exploded: &Exploded, owners: &BTreeSet<String>) -> BTreeSet<usize> {
235    let macros: BTreeMap<usize, String> = exploded
236        .manifest
237        .rows
238        .iter()
239        .filter(|r| r.kind == "macro_rules")
240        .map(|r| (r.chunk_index, r.name.clone()))
241        .collect();
242    if macros.is_empty() {
243        return BTreeSet::new();
244    }
245    let names: BTreeSet<&str> = macros.values().map(String::as_str).collect();
246    let mut consumers: BTreeMap<String, Vec<usize>> = BTreeMap::new();
247    let mut dependencies: BTreeMap<usize, Vec<String>> = BTreeMap::new();
248    let mut pending = BTreeSet::new();
249    for row in &exploded.manifest.rows {
250        if matches!(row.kind.as_str(), "preamble" | "use" | "extern_crate") {
251            continue;
252        }
253        let mut refs = crate::referenced_idents(&exploded.chunks[row.chunk_index].text)
254            .unwrap_or_else(|| names.iter().map(|s| (*s).to_owned()).collect());
255        if let Some(name) = macros.get(&row.chunk_index) {
256            refs.insert(name.clone());
257        }
258        let refs: Vec<String> = refs
259            .into_iter()
260            .filter(|s| names.contains(s.as_str()))
261            .collect();
262        if (matches!(row.kind.as_str(), "struct" | "enum" | "union") && owners.contains(&row.name))
263            || (row.kind == "impl"
264                && has_owner_local_members(&exploded.chunks[row.chunk_index].text))
265        {
266            pending.extend(refs.iter().cloned());
267        }
268        for name in &refs {
269            consumers
270                .entry(name.clone())
271                .or_default()
272                .push(row.chunk_index);
273        }
274        dependencies.insert(row.chunk_index, refs);
275    }
276    let mut seen = BTreeSet::new();
277    let mut kept = BTreeSet::new();
278    while let Some(name) = pending.pop_first() {
279        if !seen.insert(name.clone()) {
280            continue;
281        }
282        for &index in &consumers[&name] {
283            if kept.insert(index) {
284                pending.extend(dependencies[&index].iter().cloned());
285            }
286        }
287    }
288    kept
289}
290
291/// Recognise only an unqualified or `self::` type in an inherent impl. Generic
292/// arguments do not change ownership; paths through another module, aliases,
293/// trait-only impls and macro-generated impls provide no ownership evidence.
294/// Scan even oversized impls so the choice does not depend on the LOC budget.
295fn inherent_type_names(exploded: &Exploded) -> std::collections::BTreeSet<String> {
296    exploded
297        .manifest
298        .rows
299        .iter()
300        .filter(|row| row.kind == "impl")
301        .filter_map(|row| {
302            let syn::Item::Impl(item) =
303                syn::parse_str(&exploded.chunks[row.chunk_index].text).ok()?
304            else {
305                return None;
306            };
307            if item.trait_.is_some() {
308                return None;
309            }
310            let syn::Type::Path(ty) = item.self_ty.as_ref() else {
311                return None;
312            };
313            if ty.qself.is_some() || ty.path.leading_colon.is_some() {
314                return None;
315            }
316            let segments = &ty.path.segments;
317            if segments.len() == 1 || (segments.len() == 2 && segments[0].ident == "self") {
318                Some(segments.last()?.ident.to_string())
319            } else {
320                None
321            }
322        })
323        .collect()
324}
325
326/// Whether a `use`/`extern crate` chunk is a **re-export** (visibility-
327/// qualified: `pub`, `pub(crate)`, `pub(super)`, `pub(in ...)`). Re-exports are
328/// part of the file's API surface and must stay at the root verbatim; a plain
329/// private import is a header chunk copied into the files that reference it.
330fn is_reexport(chunk_text: &str) -> bool {
331    match syn::parse_str::<syn::Item>(chunk_text) {
332        Ok(syn::Item::Use(item)) => !matches!(item.vis, syn::Visibility::Inherited),
333        Ok(syn::Item::ExternCrate(item)) => !matches!(item.vis, syn::Visibility::Inherited),
334        // Unparseable or unexpected: keeping it in the copied header is the
335        // conservative choice (nothing is dropped from the root's own uses).
336        _ => false,
337    }
338}
339
340/// `Some(true)` for a body-carrying `mod name { ... }`, `Some(false)` for a
341/// content-less `mod name;` declaration, `None` when the chunk does not
342/// re-parse as a mod.
343fn mod_body(chunk_text: &str) -> Option<bool> {
344    match syn::parse_str::<syn::Item>(chunk_text) {
345        Ok(syn::Item::Mod(item)) => Some(item.content.is_some()),
346        _ => None,
347    }
348}
349
350/// Greedy agglomerative clustering on reference edges.
351///
352/// The ceiling is not a packing target: each connected component (the set of
353/// transitively related items) gets a **balanced share** — a component whose
354/// total fits within half the ceiling becomes one part; a bigger one is
355/// partitioned into `ceil(total / (max_loc / 2))` roughly equal parts. Merging
356/// is heaviest-edge-first up to that share, so the strongest relations bond
357/// first and the cuts fall on the weakest edges; same-component leftovers are
358/// then consolidated in source order up to the same share. Unrelated items
359/// (separate components) are never merged.
360fn cluster(items: &[Item], max_loc: usize) -> Vec<Part> {
361    let n = items.len();
362
363    // Section + name -> item index. Cross-section references cannot join
364    // responsibilities, including through an unbannered helper or shared type.
365    let mut name_to_item: HashMap<(usize, &str), usize> = HashMap::new();
366    for (i, item) in items.iter().enumerate() {
367        name_to_item
368            .entry((item.section, item.name.as_str()))
369            .or_insert(i);
370    }
371
372    // weighted undirected reference edges
373    let mut weights: HashMap<(usize, usize), usize> = HashMap::new();
374    for (i, item) in items.iter().enumerate() {
375        for reference in &item.refs {
376            if let Some(&j) = name_to_item.get(&(item.section, reference.as_str()))
377                && i != j
378            {
379                *weights.entry((i.min(j), i.max(j))).or_insert(0) += 1;
380            }
381        }
382    }
383    let mut edges: Vec<((usize, usize), usize)> = weights.into_iter().collect();
384    // heaviest edge first; ties by source position for determinism
385    edges.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
386
387    // Connected components over all edges, then each component's balanced
388    // per-part share.
389    let mut component: Vec<usize> = (0..n).collect();
390    for &((i, j), _weight) in &edges {
391        let ri = find(&mut component, i);
392        let rj = find(&mut component, j);
393        if ri != rj {
394            component[rj] = ri;
395        }
396    }
397    let mut component_loc: HashMap<usize, usize> = HashMap::new();
398    for (i, item) in items.iter().enumerate() {
399        let root = find(&mut component, i);
400        *component_loc.entry(root).or_insert(0) += item.loc;
401    }
402    let target = (max_loc / 2).max(1);
403    let share = |component_root: usize, component_loc: &HashMap<usize, usize>| -> usize {
404        let total = component_loc[&component_root];
405        if total <= target {
406            total
407        } else {
408            total.div_ceil(total.div_ceil(target))
409        }
410    };
411
412    let mut parent: Vec<usize> = (0..n).collect();
413    let mut group_loc: Vec<usize> = items.iter().map(|item| item.loc).collect();
414    for ((i, j), _weight) in edges {
415        let cap = share(find(&mut component, i), &component_loc);
416        let ri = find(&mut parent, i);
417        let rj = find(&mut parent, j);
418        if ri != rj && group_loc[ri] + group_loc[rj] <= cap {
419            parent[rj] = ri;
420            group_loc[ri] += group_loc[rj];
421        }
422    }
423
424    // Consolidate leftovers: clusters of the same component that the capped
425    // edge pass left apart (e.g. the satellites of an already-full hub) are
426    // still transitively related, so pack them in source order up to the same
427    // share rather than emitting a file per satellite.
428    let mut first_chunk: HashMap<usize, usize> = HashMap::new();
429    for (i, item) in items.iter().enumerate() {
430        let root = find(&mut parent, i);
431        let first = first_chunk.entry(root).or_insert(usize::MAX);
432        *first = (*first).min(item.chunk_index);
433    }
434    let mut by_component: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
435    for i in 0..n {
436        if find(&mut parent, i) == i {
437            let component_root = find(&mut component, i);
438            by_component.entry(component_root).or_default().push(i);
439        }
440    }
441    for (component_root, mut cluster_roots) in by_component {
442        let cap = share(component_root, &component_loc);
443        cluster_roots.sort_by_key(|root| first_chunk[root]);
444        let mut accumulator: Option<usize> = None;
445        for root in cluster_roots {
446            match accumulator {
447                Some(acc) if group_loc[acc] + group_loc[root] <= cap => {
448                    parent[root] = acc;
449                    group_loc[acc] += group_loc[root];
450                }
451                _ => accumulator = Some(root),
452            }
453        }
454    }
455
456    // collect members per root
457    let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
458    for i in 0..n {
459        let root = find(&mut parent, i);
460        groups.entry(root).or_default().push(i);
461    }
462
463    let mut parts: Vec<Part> = groups
464        .into_values()
465        .map(|members| {
466            let mut chunk_indices: Vec<usize> =
467                members.iter().map(|&i| items[i].chunk_index).collect();
468            chunk_indices.sort_unstable();
469            let loc = members.iter().map(|&i| items[i].loc).sum();
470            Part {
471                name: cluster_name(&members, items),
472                chunk_indices,
473                loc,
474            }
475        })
476        .collect();
477
478    parts.sort_by_key(|part| part.chunk_indices.first().copied().unwrap_or(0));
479    dedupe_names(&mut parts);
480    parts
481}
482
483fn find(parent: &mut [usize], mut x: usize) -> usize {
484    while parent[x] != x {
485        parent[x] = parent[parent[x]]; // path halving
486        x = parent[x];
487    }
488    x
489}
490
491/// Name a cluster after the first section banner heading one of its items,
492/// else after its largest nameable item (skipping `impl`/`use`). The name never
493/// shadows a crate or collides with a keyword (see `naming`).
494fn cluster_name(members: &[usize], items: &[Item]) -> String {
495    let banner = members
496        .iter()
497        .min_by_key(|&&i| (items[i].title.is_none(), items[i].chunk_index))
498        .and_then(|&i| items[i].title.as_deref());
499    if let Some(title) = banner {
500        return naming::module_name(title);
501    }
502    let nameable = |kind: &str| {
503        matches!(
504            kind,
505            "fn" | "struct"
506                | "enum"
507                | "const"
508                | "static"
509                | "trait"
510                | "trait_alias"
511                | "type"
512                | "union"
513                | "mod"
514        )
515    };
516    let dominant = members
517        .iter()
518        .filter(|&&i| nameable(&items[i].kind))
519        .max_by_key(|&&i| items[i].loc)
520        .or_else(|| members.iter().max_by_key(|&&i| items[i].loc));
521    match dominant {
522        Some(&i) => naming::module_name(&items[i].name),
523        None => "group".to_owned(),
524    }
525}
526
527fn dedupe_names(parts: &mut [Part]) {
528    let mut seen: HashMap<String, usize> = HashMap::new();
529    for part in parts.iter_mut() {
530        let count = seen.entry(part.name.clone()).or_insert(0);
531        *count += 1;
532        if *count > 1 {
533            // `core_` twice becomes `core_2`, not `core__2`.
534            part.name = format!("{}_{}", part.name.trim_end_matches('_'), *count);
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::explode;
543
544    fn fns(defs: &[(&str, &[&str])]) -> String {
545        // each (name, calls) -> a fn that calls the named siblings
546        let mut src = String::new();
547        for (name, calls) in defs {
548            src.push_str(&format!("fn {name}() {{\n"));
549            for c in *calls {
550                src.push_str(&format!("    {c}();\n"));
551            }
552            src.push_str("}\n\n");
553        }
554        src
555    }
556
557    #[test]
558    fn unrelated_items_become_separate_files_not_merged() {
559        // ten items that reference nothing -> ten clusters (NOT fewer)
560        let src = fns(&[
561            ("a", &[]),
562            ("b", &[]),
563            ("c", &[]),
564            ("d", &[]),
565            ("e", &[]),
566            ("f", &[]),
567            ("g", &[]),
568            ("h", &[]),
569            ("i", &[]),
570            ("j", &[]),
571        ]);
572        let exploded = explode(&src).unwrap();
573        // budget is huge: they would all fit in one file, but they are unrelated
574        let plan = plan_split(&exploded, 10_000);
575        assert_eq!(plan.parts.len(), 10, "unrelated items must not be merged");
576    }
577
578    #[test]
579    fn related_items_cluster_together_unrelated_stay_apart() {
580        // group A: a1->a2->a3 ; group B: b1->b2 ; loner c
581        let src = fns(&[
582            ("a1", &["a2"]),
583            ("a2", &["a3"]),
584            ("a3", &[]),
585            ("b1", &["b2"]),
586            ("b2", &[]),
587            ("c", &[]),
588        ]);
589        let exploded = explode(&src).unwrap();
590        let plan = plan_split(&exploded, 10_000);
591        assert_eq!(plan.parts.len(), 3, "two clusters + one loner");
592        let sizes: Vec<usize> = plan.parts.iter().map(|p| p.chunk_indices.len()).collect();
593        assert!(sizes.contains(&3) && sizes.contains(&2) && sizes.contains(&1));
594    }
595
596    #[test]
597    fn a_cohesive_group_over_the_ceiling_is_split() {
598        // a chain a->b->c->d->e, each ~4 LOC, ceiling 10 -> cannot be one module
599        let src = fns(&[
600            ("a", &["b"]),
601            ("b", &["c"]),
602            ("c", &["d"]),
603            ("d", &["e"]),
604            ("e", &[]),
605        ]);
606        let exploded = explode(&src).unwrap();
607        let plan = plan_split(&exploded, 10);
608        assert!(plan.parts.len() >= 2, "ceiling forces a split");
609        for part in &plan.parts {
610            assert!(part.loc < 10, "part over ceiling: {}", part.loc);
611        }
612    }
613
614    #[test]
615    fn imports_go_to_the_header_and_clusters_are_named() {
616        let src = "use std::fmt;\n\nfn alpha() { beta(); }\n\nfn beta() {}\n";
617        let exploded = explode(src).unwrap();
618        let plan = plan_split(&exploded, 10_000);
619        assert_eq!(plan.header.len(), 1);
620        assert_eq!(plan.parts.len(), 1, "alpha+beta cluster");
621        assert!(!plan.parts[0].name.is_empty());
622        assert!(
623            plan.parts[0]
624                .name
625                .chars()
626                .all(|c| c.is_ascii_lowercase() || c == '_')
627        );
628    }
629
630    #[test]
631    fn an_oversized_leaf_is_reported_not_forced() {
632        let src =
633            "fn big() {\n    let _a = 1;\n    let _b = 2;\n    let _c = 3;\n    let _d = 4;\n}\n";
634        let exploded = explode(src).unwrap();
635        let plan = plan_split(&exploded, 5);
636        assert_eq!(plan.oversized.len(), 1);
637        assert!(!plan.oversized[0].recoverable);
638        assert!(plan.parts.is_empty());
639    }
640
641    // ----- razel regression: re-exports, mod decls, and body mods never cluster
642
643    #[test]
644    fn reexports_mod_decls_and_body_mods_are_kept_out_of_clustering() {
645        let src = "use std::fmt;\n\nmod helpers;\n\npub use helpers::Helper;\n\n\
646                   pub fn api() -> i32 {\n    1\n}\n\n\
647                   #[cfg(test)]\nmod tests {\n    fn smoke() {}\n}\n";
648        let exploded = explode(src).unwrap();
649        let plan = plan_split(&exploded, 10_000);
650
651        let text = |indices: &[usize]| -> String {
652            indices
653                .iter()
654                .map(|&i| exploded.chunks[i].text.as_str())
655                .collect()
656        };
657        let header = text(&plan.header);
658        assert!(header.contains("use std::fmt;"), "plain import -> header");
659        assert!(
660            !header.contains("pub use"),
661            "re-export is not a header import"
662        );
663
664        let root_items = text(&plan.root_items);
665        assert!(
666            root_items.contains("mod helpers;"),
667            "content-less mod decl stays at the root"
668        );
669        assert!(
670            root_items.contains("pub use helpers::Helper;"),
671            "pub use re-export stays at the root"
672        );
673
674        assert_eq!(plan.mods.len(), 1, "body mod is extracted, never clustered");
675        assert!(text(&plan.mods).contains("#[cfg(test)]"));
676        assert_eq!(plan.parts.len(), 1, "only `api` is left to cluster");
677        assert!(plan.oversized.is_empty());
678    }
679
680    // ----- razel regression: the ceiling is not a packing target ---------------
681
682    #[test]
683    fn a_big_connected_component_is_partitioned_not_packed_to_the_ceiling() {
684        // A chain of 12 related fns, ~11 LOC each (~132 total), budget 100.
685        // Packing-to-the-ceiling would emit one ~99-LOC file plus a remainder;
686        // the balanced share is ceil(132 / ceil(132/50)) = 44 -> three parts.
687        let mut src = String::new();
688        for i in 0..12 {
689            let call = if i < 11 {
690                format!("    chain{}();\n", i + 1)
691            } else {
692                String::new()
693            };
694            src.push_str(&format!(
695                "fn chain{i}() {{\n{call}    let _a = 1;\n    let _b = 2;\n    let _c = 3;\n    \
696                 let _d = 4;\n    let _e = 5;\n    let _f = 6;\n    let _g = 7;\n}}\n\n"
697            ));
698        }
699        let exploded = explode(&src).unwrap();
700        let plan = plan_split(&exploded, 100);
701
702        assert!(
703            plan.parts.len() >= 3,
704            "a ~132-LOC component under a 100 ceiling must give >= 3 parts, got {}: {:?}",
705            plan.parts.len(),
706            plan.parts.iter().map(|p| p.loc).collect::<Vec<_>>()
707        );
708        for part in &plan.parts {
709            assert!(
710                part.loc <= 50,
711                "part must stay near the balanced share (<= half the ceiling), got {}",
712                part.loc
713            );
714        }
715    }
716
717    #[test]
718    fn satellites_of_a_full_hub_consolidate_instead_of_one_file_each() {
719        // hub + 20 satellites that each call it: once the hub's cluster reaches
720        // the balanced share, the leftover satellites are still transitively
721        // related and must consolidate, not become 16 tiny files.
722        let mut src = String::from("fn hub() {}\n\n");
723        for i in 0..20 {
724            src.push_str(&format!(
725                "fn sat{i}() {{\n    hub();\n    let _x = {i};\n}}\n\n"
726            ));
727        }
728        let exploded = explode(&src).unwrap();
729        let plan = plan_split(&exploded, 60);
730
731        assert!(
732            plan.parts.len() <= 6,
733            "satellites must consolidate, got {} parts",
734            plan.parts.len()
735        );
736        for part in &plan.parts {
737            assert!(part.loc <= 30, "part over the balanced share: {}", part.loc);
738        }
739    }
740}