1use scc_core::estimate_tokens;
15use std::collections::BTreeMap;
16
17pub fn skeleton_budget(total: usize) -> usize {
22 (total / 10).clamp(64, 800).min(total.max(64))
23}
24
25pub struct Skeleton {
28 pub text: String,
30 pub top_entries: usize,
32 pub files_seen: usize,
34 pub collapsed: usize,
36}
37
38const MAX_TOP: usize = 64;
40const MAX_EXPAND_PER_TOP: usize = 10;
42const MAX_DEPTH3_PER_TOP: usize = 8;
44const MAX_DEPTH3_KIDS: usize = 4;
46
47const MANIFESTS: &[&str] = &[
49 "Cargo.toml",
50 "package.json",
51 "go.mod",
52 "pyproject.toml",
53 "setup.cfg",
54 "pom.xml",
55 "build.gradle",
56];
57
58fn priority_top(top: &str, members: &[String]) -> bool {
66 members.iter().any(|m| {
67 let rest = m.strip_prefix(top).unwrap_or(m).trim_start_matches('/');
68 if rest == "src" || rest.starts_with("src/") {
69 return true;
70 }
71 let mut segs = rest.split('/');
72 match (segs.next(), segs.next(), segs.next()) {
73 (Some(_), None, _) => MANIFESTS.contains(&rest),
74 (Some(_), Some(last), None) => MANIFESTS.contains(&last),
75 _ => false,
76 }
77 })
78}
79
80pub fn build_skeleton(paths: &[String], budget_tokens: usize) -> Skeleton {
88 let mut tops: BTreeMap<String, Vec<String>> = BTreeMap::new();
89 let mut loose: Vec<String> = Vec::new();
90 for p in paths {
91 match p.split_once('/') {
92 Some((top, _)) => tops.entry(top.to_string()).or_default().push(p.clone()),
93 None => loose.push(p.clone()),
94 }
95 }
96 loose.sort();
97 loose.dedup();
98
99 let mut top_lines: Vec<String> = Vec::new();
100 let mut expansions: Vec<(bool, Vec<String>, Vec<String>)> = Vec::new();
101 for (top, members) in tops.iter().take(MAX_TOP) {
102 let role = scc_graph::components::component_role(std::slice::from_ref(top));
103 top_lines.push(format!("- {top}/ [{role}] ({} files)", members.len()));
104 if role == "production" || role == "example" || role == "sdk" {
105 let priority = priority_top(top, members);
106 let (d2, d3) = expand_top(top, members, priority);
107 expansions.push((priority, d2, d3));
108 }
109 }
110 for f in &loose {
111 top_lines.push(format!("- {f}"));
112 }
113 let omitted_tops = tops.len().saturating_sub(MAX_TOP);
114
115 let mut lines: Vec<String> = Vec::new();
116 let mut collapsed: usize = omitted_tops;
117 let mut used = 0usize;
118 for line in top_lines {
119 let cost = estimate_tokens(&line);
120 if used + cost > budget_tokens {
121 collapsed += 1;
122 continue;
123 }
124 used += cost;
125 lines.push(line);
126 }
127 for (priority, d2, d3) in &expansions {
128 for line in d2.iter().take(MAX_EXPAND_PER_TOP) {
129 let cost = estimate_tokens(line);
130 if used + cost > budget_tokens {
131 collapsed += 1;
132 continue;
133 }
134 used += cost;
135 lines.push(line.clone());
136 }
137 collapsed += d2.len().saturating_sub(MAX_EXPAND_PER_TOP);
138 if *priority {
141 for line in d3.iter().take(MAX_DEPTH3_PER_TOP) {
142 let cost = estimate_tokens(line);
143 if used + cost > budget_tokens {
144 collapsed += 1;
145 continue;
146 }
147 used += cost;
148 lines.push(line.clone());
149 }
150 collapsed += d3.len().saturating_sub(MAX_DEPTH3_PER_TOP);
151 }
152 }
153 let text = if collapsed > 0 {
154 format!(
155 "{}\n... (+{collapsed} entries omitted over skeleton budget)",
156 lines.join("\n")
157 )
158 } else {
159 lines.join("\n")
160 };
161 Skeleton {
162 text,
163 top_entries: tops.len().min(MAX_TOP) + loose.len(),
164 files_seen: paths.len(),
165 collapsed,
166 }
167}
168
169fn expand_top(top: &str, members: &[String], priority: bool) -> (Vec<String>, Vec<String>) {
174 let mut kids: BTreeMap<String, usize> = BTreeMap::new();
175 let mut grand: BTreeMap<String, Vec<String>> = BTreeMap::new();
177 let mut loose_files: Vec<String> = Vec::new();
178 for m in members {
179 let rest = m.strip_prefix(top).unwrap_or(m).trim_start_matches('/');
180 match rest.split_once('/') {
181 Some((kid, _)) => {
182 *kids.entry(kid.to_string()).or_default() += 1;
183 if priority {
184 grand.entry(kid.to_string()).or_default().push(m.clone());
185 }
186 }
187 None => loose_files.push(rest.to_string()),
188 }
189 }
190 loose_files.sort();
191 loose_files.dedup();
192 let mut d2 = Vec::new();
193 for f in loose_files.into_iter().take(8) {
194 d2.push(format!(" - {top}/{f}"));
195 }
196 for (kid, n) in &kids {
197 d2.push(format!(" - {top}/{kid}/ ({n} files)"));
198 }
199 let mut d3 = Vec::new();
200 if priority {
201 for (kid, paths) in &grand {
202 let prefix = format!("{top}/{kid}/");
203 let mut files: Vec<String> = Vec::new();
204 let mut subdirs: BTreeMap<String, usize> = BTreeMap::new();
205 for full in paths {
206 let rest = full.strip_prefix(&prefix).unwrap_or(full);
207 match rest.split_once('/') {
208 Some((sub, _)) => *subdirs.entry(sub.to_string()).or_default() += 1,
209 None => files.push(rest.to_string()),
210 }
211 }
212 files.sort();
213 files.dedup();
214 for f in files.into_iter().take(MAX_DEPTH3_KIDS) {
215 d3.push(format!(" - {prefix}{f}"));
216 }
217 for (sub, n) in subdirs.iter().take(MAX_DEPTH3_KIDS) {
218 d3.push(format!(" - {prefix}{sub}/ ({n} files)"));
219 }
220 }
221 }
222 (d2, d3)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn paths(xs: &[&str]) -> Vec<String> {
231 xs.iter().map(|s| s.to_string()).collect()
232 }
233
234 #[test]
235 fn skeleton_is_deterministic_and_role_labeled() {
237 let a = build_skeleton(
238 &paths(&[
239 "crates/scc-core/src/lib.rs",
240 "crates/scc-cli/src/main.rs",
241 "fixtures/http-service-python/main.py",
242 "Cargo.toml",
243 ]),
244 800,
245 );
246 let b = build_skeleton(
247 &paths(&[
248 "Cargo.toml",
249 "fixtures/http-service-python/main.py",
250 "crates/scc-cli/src/main.rs",
251 "crates/scc-core/src/lib.rs",
252 ]),
253 800,
254 );
255 assert_eq!(a.text, b.text, "input order must not matter");
256 assert!(a.text.contains("crates/ [production]"), "{}", a.text);
257 assert!(a.text.contains("fixtures/ [fixture]"), "{}", a.text);
258 assert!(a.text.contains("- Cargo.toml"), "{}", a.text);
259 assert!(a.text.contains("scc-core/"), "{}", a.text);
260 assert_eq!(a.collapsed, 0);
261 assert_eq!(a.files_seen, 4);
262 }
263
264 #[test]
265 fn skeleton_never_exceeds_its_budget() {
267 let many: Vec<String> = (0..300)
268 .map(|i| format!("crates/svc{i}/src/mod{i}/file{i}.rs"))
269 .collect();
270 let sk = build_skeleton(&many, 200);
271 assert!(
272 estimate_tokens(&sk.text) <= 200,
273 "skeleton must fit budget: {}",
274 estimate_tokens(&sk.text)
275 );
276 assert!(sk.collapsed > 0, "overflow must be counted, not silent");
277 assert!(sk.text.contains("omitted over skeleton budget"));
278 }
279
280 #[test]
281 fn skeleton_expands_priority_tops_one_level_deeper() {
283 let sk = build_skeleton(
286 &paths(&[
287 "crates/scc-core/Cargo.toml",
288 "crates/scc-core/src/lib.rs",
289 "crates/scc-core/src/resolve.rs",
290 "plains/a.txt",
291 "plains/sub/b.txt",
292 ]),
293 800,
294 );
295 assert!(sk.text.contains(" - crates/scc-core/src/ (2 files)"), "{}", sk.text);
296 assert!(sk.text.contains(" - crates/scc-core/Cargo.toml"), "{}", sk.text);
297 assert!(!sk.text.contains(" "), "no depth-4: {}", sk.text);
298 let big: Vec<String> = (0..200)
300 .map(|i| format!("crates/svc{i}/Cargo.toml"))
301 .chain((0..200).map(|i| format!("crates/svc{i}/src/lib.rs")))
302 .collect();
303 let sk2 = build_skeleton(&big, 200);
304 assert!(
305 estimate_tokens(&sk2.text) <= 200,
306 "skeleton must fit budget: {}",
307 estimate_tokens(&sk2.text)
308 );
309 }
310
311 #[test]
312 fn skeleton_budget_is_bounded_fraction() {
314 assert_eq!(skeleton_budget(20_000), 800);
315 assert_eq!(skeleton_budget(1_000), 100);
316 assert!(skeleton_budget(64) <= 64);
317 }
318}