1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4use std::thread;
5
6pub const FILE_CHAR_CAP: usize = 20_000;
7pub const PER_FILE_OVERHEAD_CHARS: usize = 80;
8pub const CHARS_PER_TOKEN: usize = 4;
9pub const DEFAULT_TOKEN_BUDGET: usize = 60_000;
10
11#[derive(Debug, Clone)]
12pub struct ChunkResult {
13 pub nodes: Vec<serde_json::Value>,
14 pub edges: Vec<serde_json::Value>,
15 pub hyperedges: Vec<serde_json::Value>,
16 pub input_tokens: usize,
17 pub output_tokens: usize,
18 pub finish_reason: String,
19 pub model: Option<String>,
20 pub failed_chunks: usize,
21}
22
23impl ChunkResult {
24 pub fn empty() -> Self {
25 ChunkResult {
26 nodes: vec![],
27 edges: vec![],
28 hyperedges: vec![],
29 input_tokens: 0,
30 output_tokens: 0,
31 finish_reason: "stop".to_string(),
32 model: None,
33 failed_chunks: 0,
34 }
35 }
36
37 pub fn merge(mut self, other: ChunkResult) -> ChunkResult {
38 self.nodes.extend(other.nodes);
39 self.edges.extend(other.edges);
40 self.hyperedges.extend(other.hyperedges);
41 self.input_tokens += other.input_tokens;
42 self.output_tokens += other.output_tokens;
43 self.failed_chunks += other.failed_chunks;
44 self.finish_reason = "stop".to_string();
45 self
46 }
47}
48
49pub fn read_chunk_content(path: &Path) -> String {
50 match std::fs::read(path) {
51 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
52 Err(_) => String::new(),
53 }
54}
55
56pub fn estimate_file_tokens(path: &Path) -> usize {
57 let size = match path.metadata() {
58 Ok(m) => m.len() as usize,
59 Err(_) => return 0,
60 };
61 let chars = size.min(FILE_CHAR_CAP) + PER_FILE_OVERHEAD_CHARS;
62 chars / CHARS_PER_TOKEN
63}
64
65pub fn pack_chunks_by_tokens(
66 files: &[PathBuf],
67 token_budget: usize,
68) -> Result<Vec<Vec<PathBuf>>, String> {
69 if token_budget == 0 {
70 return Err(format!("token_budget must be positive, got {token_budget}"));
71 }
72
73 let mut by_dir: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
74 for f in files {
75 let parent = f.parent().unwrap_or(Path::new(".")).to_path_buf();
76 by_dir.entry(parent).or_default().push(f.clone());
77 }
78
79 let mut sorted_dirs: Vec<PathBuf> = by_dir.keys().cloned().collect();
80 sorted_dirs.sort();
81
82 let mut chunks: Vec<Vec<PathBuf>> = vec![];
83 let mut current: Vec<PathBuf> = vec![];
84 let mut current_tokens: usize = 0;
85
86 for dir in &sorted_dirs {
87 for path in &by_dir[dir] {
88 let cost = estimate_file_tokens(path);
89 if !current.is_empty() && current_tokens + cost > token_budget {
90 chunks.push(current.clone());
91 current = vec![];
92 current_tokens = 0;
93 }
94 current.push(path.clone());
95 current_tokens += cost;
96 }
97 }
98
99 if !current.is_empty() {
100 chunks.push(current);
101 }
102 Ok(chunks)
103}
104
105fn looks_like_context_exceeded(msg: &str) -> bool {
106 let lower = msg.to_lowercase();
107 let markers = [
108 "context size",
109 "context length",
110 "context_length",
111 "context window",
112 "n_keep",
113 "exceeds the available",
114 "n_ctx",
115 "maximum context",
116 "too many tokens",
117 "prompt is too long",
118 "context_length_exceeded",
119 ];
120 markers.iter().any(|m| lower.contains(m))
121}
122
123pub fn extract_with_adaptive_retry<F>(
124 chunk: &[PathBuf],
125 extractor: &F,
126 max_depth: usize,
127 depth: usize,
128) -> ChunkResult
129where
130 F: Fn(&[PathBuf]) -> Result<ChunkResult, String> + Sync,
131{
132 match extractor(chunk) {
133 Err(e) => {
134 if !looks_like_context_exceeded(&e) {
135 eprintln!("[codesynapse] chunk extraction failed: {e}");
136 return ChunkResult {
137 failed_chunks: 1,
138 ..ChunkResult::empty()
139 };
140 }
141 if chunk.len() <= 1 {
142 eprintln!(
143 "[codesynapse] single-file chunk {:?} exceeds model context and cannot be split further: {}",
144 chunk.first(),
145 e
146 );
147 return ChunkResult::empty();
148 }
149 if depth >= max_depth {
150 eprintln!(
151 "[codesynapse] chunk of {} still overflows context at recursion depth {} (max {}) — dropping",
152 chunk.len(),
153 depth,
154 max_depth
155 );
156 return ChunkResult::empty();
157 }
158 eprintln!(
159 "[codesynapse] chunk of {} exceeded context at depth {} ({}); splitting in half and retrying",
160 chunk.len(),
161 depth,
162 e
163 );
164 let mid = chunk.len() / 2;
165 let left = extract_with_adaptive_retry(&chunk[..mid], extractor, max_depth, depth + 1);
166 let right = extract_with_adaptive_retry(&chunk[mid..], extractor, max_depth, depth + 1);
167 left.merge(right)
168 }
169 Ok(result) => {
170 if result.finish_reason != "length" {
171 return result;
172 }
173 if chunk.len() <= 1 {
174 eprintln!(
175 "[codesynapse] single-file chunk {:?} truncated at max_completion_tokens — partial result kept",
176 chunk.first()
177 );
178 return result;
179 }
180 if depth >= max_depth {
181 eprintln!(
182 "[codesynapse] chunk of {} still truncated at recursion depth {} (max {}) — partial result kept",
183 chunk.len(),
184 depth,
185 max_depth
186 );
187 return result;
188 }
189 eprintln!(
190 "[codesynapse] chunk of {} truncated at depth {}, splitting into halves of {} and {}",
191 chunk.len(),
192 depth,
193 chunk.len() / 2,
194 chunk.len() - chunk.len() / 2
195 );
196 let mid = chunk.len() / 2;
197 let left = extract_with_adaptive_retry(&chunk[..mid], extractor, max_depth, depth + 1);
198 let right = extract_with_adaptive_retry(&chunk[mid..], extractor, max_depth, depth + 1);
199 left.merge(right)
200 }
201 }
202}
203
204pub struct CorpusParallelConfig {
205 pub chunk_size: usize,
206 pub token_budget: Option<usize>,
207 pub max_concurrency: usize,
208 pub max_retry_depth: usize,
209}
210
211impl Default for CorpusParallelConfig {
212 fn default() -> Self {
213 CorpusParallelConfig {
214 chunk_size: 20,
215 token_budget: Some(DEFAULT_TOKEN_BUDGET),
216 max_concurrency: 4,
217 max_retry_depth: 3,
218 }
219 }
220}
221
222pub fn extract_corpus_parallel<F, C>(
223 files: &[PathBuf],
224 config: CorpusParallelConfig,
225 extractor: F,
226 on_chunk_done: Option<C>,
227) -> ChunkResult
228where
229 F: Fn(&[PathBuf]) -> Result<ChunkResult, String> + Sync + Send + 'static,
230 C: Fn(usize, usize, &ChunkResult) + Send + Sync + 'static,
231{
232 let chunks: Vec<Vec<PathBuf>> = if let Some(budget) = config.token_budget {
233 pack_chunks_by_tokens(files, budget).unwrap_or_else(|_| {
234 files
235 .chunks(config.chunk_size)
236 .map(|c| c.to_vec())
237 .collect()
238 })
239 } else {
240 files
241 .chunks(config.chunk_size)
242 .map(|c| c.to_vec())
243 .collect()
244 };
245
246 let total = chunks.len();
247 let max_retry_depth = config.max_retry_depth;
248 let workers = config.max_concurrency.max(1).min(total.max(1));
249
250 let merged = Arc::new(Mutex::new(ChunkResult::empty()));
251 let extractor = Arc::new(extractor);
252 let on_chunk_done: Arc<Option<C>> = Arc::new(on_chunk_done);
253
254 if workers == 1 {
255 for (idx, chunk) in chunks.iter().enumerate() {
256 let result = extract_with_adaptive_retry(chunk, extractor.as_ref(), max_retry_depth, 0);
257 {
258 let mut m = merged.lock().unwrap();
259 *m = ChunkResult {
260 nodes: {
261 let mut v = m.nodes.clone();
262 v.extend(result.nodes.clone());
263 v
264 },
265 edges: {
266 let mut v = m.edges.clone();
267 v.extend(result.edges.clone());
268 v
269 },
270 hyperedges: {
271 let mut v = m.hyperedges.clone();
272 v.extend(result.hyperedges.clone());
273 v
274 },
275 input_tokens: m.input_tokens + result.input_tokens,
276 output_tokens: m.output_tokens + result.output_tokens,
277 failed_chunks: m.failed_chunks + result.failed_chunks,
278 finish_reason: "stop".to_string(),
279 model: m.model.clone(),
280 };
281 }
282 if let Some(cb) = on_chunk_done.as_ref() {
283 cb(idx, total, &result);
284 }
285 }
286 } else {
287 let mut handles = vec![];
288 for (idx, chunk) in chunks.into_iter().enumerate() {
289 let ext = Arc::clone(&extractor);
290 let merged_clone = Arc::clone(&merged);
291 let cb_clone = Arc::clone(&on_chunk_done);
292
293 let handle = thread::spawn(move || {
294 let result = extract_with_adaptive_retry(&chunk, ext.as_ref(), max_retry_depth, 0);
295 {
296 let mut m = merged_clone.lock().unwrap();
297 m.nodes.extend(result.nodes.clone());
298 m.edges.extend(result.edges.clone());
299 m.hyperedges.extend(result.hyperedges.clone());
300 m.input_tokens += result.input_tokens;
301 m.output_tokens += result.output_tokens;
302 m.failed_chunks += result.failed_chunks;
303 }
304 if let Some(cb) = cb_clone.as_ref() {
305 cb(idx, total, &result);
306 }
307 });
308 handles.push(handle);
309 }
310 for h in handles {
311 let _ = h.join();
312 }
313 }
314
315 let result = Arc::try_unwrap(merged).unwrap().into_inner().unwrap();
316 if result.failed_chunks > 0 {
317 eprintln!(
318 "[codesynapse] WARNING: {}/{} chunks failed during extraction",
319 result.failed_chunks, total
320 );
321 }
322 result
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::fs;
329 use tempfile::TempDir;
330
331 fn make_files(dir: &Path, names: &[&str], content: &str) -> Vec<PathBuf> {
332 names
333 .iter()
334 .map(|n| {
335 let p = dir.join(n);
336 fs::write(&p, content).unwrap();
337 p
338 })
339 .collect()
340 }
341
342 #[test]
345 fn test_estimate_file_tokens_chars_fallback() {
346 let tmp = TempDir::new().unwrap();
347 let f = tmp.path().join("x.py");
348 fs::write(&f, "x".repeat(1000)).unwrap();
349 assert_eq!(estimate_file_tokens(&f), 270);
351 }
352
353 #[test]
354 fn test_estimate_file_tokens_missing_file() {
355 assert_eq!(estimate_file_tokens(Path::new("/does/not/exist.py")), 0);
356 }
357
358 #[test]
359 fn test_estimate_file_tokens_caps_at_file_char_cap() {
360 let tmp = TempDir::new().unwrap();
361 let f = tmp.path().join("big.py");
362 fs::write(&f, "x".repeat(100_000)).unwrap();
363 assert_eq!(estimate_file_tokens(&f), 5020);
365 }
366
367 #[test]
370 fn test_pack_chunks_packs_small_files_together() {
371 let tmp = TempDir::new().unwrap();
372 let files = make_files(
373 tmp.path(),
374 &(0..20)
375 .map(|i| format!("small_{i}.py"))
376 .collect::<Vec<_>>()
377 .iter()
378 .map(|s| s.as_str())
379 .collect::<Vec<_>>(),
380 "x = 1\n",
381 );
382 let chunks = pack_chunks_by_tokens(&files, 10_000).unwrap();
383 assert_eq!(chunks.len(), 1);
384 let mut got = chunks[0].clone();
385 let mut want = files.clone();
386 got.sort();
387 want.sort();
388 assert_eq!(got, want);
389 }
390
391 #[test]
392 fn test_pack_chunks_starts_new_chunk_when_budget_would_overflow() {
393 let tmp = TempDir::new().unwrap();
394 let files = make_files(
398 tmp.path(),
399 &[
400 "file_0.py",
401 "file_1.py",
402 "file_2.py",
403 "file_3.py",
404 "file_4.py",
405 ],
406 &"x".repeat(10_000),
407 );
408 let chunks = pack_chunks_by_tokens(&files, 6_000).unwrap();
409 let sizes: Vec<usize> = chunks.iter().map(|c| c.len()).collect();
410 assert_eq!(sizes, vec![2, 2, 1]);
411 assert_eq!(sizes.iter().sum::<usize>(), 5);
412 }
413
414 #[test]
415 fn test_pack_chunks_groups_by_directory() {
416 let tmp = TempDir::new().unwrap();
417 let dir_a = tmp.path().join("a");
418 let dir_b = tmp.path().join("b");
419 fs::create_dir_all(&dir_a).unwrap();
420 fs::create_dir_all(&dir_b).unwrap();
421
422 let a1 = dir_a.join("x.py");
423 fs::write(&a1, "a").unwrap();
424 let a2 = dir_a.join("y.py");
425 fs::write(&a2, "a").unwrap();
426 let b1 = dir_b.join("x.py");
427 fs::write(&b1, "b").unwrap();
428 let b2 = dir_b.join("y.py");
429 fs::write(&b2, "b").unwrap();
430
431 let chunks =
432 pack_chunks_by_tokens(&[a1.clone(), b1.clone(), a2.clone(), b2.clone()], 1_000_000)
433 .unwrap();
434 assert_eq!(chunks.len(), 1);
435 let chunk = &chunks[0];
436 let a_indices: Vec<usize> = chunk
437 .iter()
438 .enumerate()
439 .filter(|(_, p)| p.parent() == Some(dir_a.as_path()))
440 .map(|(i, _)| i)
441 .collect();
442 let b_indices: Vec<usize> = chunk
443 .iter()
444 .enumerate()
445 .filter(|(_, p)| p.parent() == Some(dir_b.as_path()))
446 .map(|(i, _)| i)
447 .collect();
448 assert_eq!(a_indices, a_indices.clone());
449 assert_eq!(b_indices, b_indices.clone());
450 assert!(
451 a_indices.iter().max().unwrap() < b_indices.iter().min().unwrap()
452 || b_indices.iter().max().unwrap() < a_indices.iter().min().unwrap()
453 );
454 }
455
456 #[test]
457 fn test_pack_chunks_oversized_file_gets_its_own_chunk() {
458 let tmp = TempDir::new().unwrap();
459 let big = tmp.path().join("big.py");
460 fs::write(&big, "x".repeat(200_000)).unwrap();
461 let small = tmp.path().join("small.py");
462 fs::write(&small, "x").unwrap();
463
464 let chunks = pack_chunks_by_tokens(&[big, small], 1_000).unwrap();
465 let sizes: Vec<usize> = chunks.iter().map(|c| c.len()).collect();
466 assert_eq!(sizes, vec![1, 1]);
467 }
468
469 #[test]
470 fn test_pack_chunks_rejects_zero_budget() {
471 let tmp = TempDir::new().unwrap();
472 let f = tmp.path().join("x.py");
473 fs::write(&f, "a").unwrap();
474 assert!(pack_chunks_by_tokens(&[f], 0).is_err());
475 }
476
477 fn stub_result(file_count: usize, finish_reason: &str) -> ChunkResult {
480 ChunkResult {
481 nodes: (0..file_count)
482 .map(|i| serde_json::json!({"id": format!("n_{i}")}))
483 .collect(),
484 edges: vec![],
485 hyperedges: vec![],
486 input_tokens: 100 * file_count,
487 output_tokens: 50 * file_count,
488 finish_reason: finish_reason.to_string(),
489 model: None,
490 failed_chunks: 0,
491 }
492 }
493
494 #[test]
495 fn test_adaptive_retry_returns_directly_when_not_truncated() {
496 let tmp = TempDir::new().unwrap();
497 let files: Vec<PathBuf> = (0..4)
498 .map(|i| {
499 let f = tmp.path().join(format!("f{i}.py"));
500 fs::write(&f, "x").unwrap();
501 f
502 })
503 .collect();
504
505 let call_count = Arc::new(Mutex::new(0usize));
506 let cc = Arc::clone(&call_count);
507 let extractor = move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
508 *cc.lock().unwrap() += 1;
509 Ok(stub_result(chunk.len(), "stop"))
510 };
511
512 let result = extract_with_adaptive_retry(&files, &extractor, 3, 0);
513 assert_eq!(*call_count.lock().unwrap(), 1);
514 assert_eq!(result.nodes.len(), 4);
515 }
516
517 #[test]
518 fn test_adaptive_retry_splits_when_finish_reason_length() {
519 let tmp = TempDir::new().unwrap();
520 let files: Vec<PathBuf> = (0..4)
521 .map(|i| {
522 let f = tmp.path().join(format!("f{i}.py"));
523 fs::write(&f, "x").unwrap();
524 f
525 })
526 .collect();
527
528 let call_sizes = Arc::new(Mutex::new(vec![]));
529 let cs = Arc::clone(&call_sizes);
530 let extractor = move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
531 cs.lock().unwrap().push(chunk.len());
532 let finish = if chunk.len() == 4 { "length" } else { "stop" };
533 Ok(stub_result(chunk.len(), finish))
534 };
535
536 let result = extract_with_adaptive_retry(&files, &extractor, 3, 0);
537 let mut sizes = call_sizes.lock().unwrap().clone();
538 sizes.sort();
539 assert_eq!(sizes, vec![2, 2, 4]);
540 assert_eq!(result.nodes.len(), 4);
541 assert_eq!(result.finish_reason, "stop");
542 }
543
544 #[test]
545 fn test_adaptive_retry_recurses_for_persistent_truncation() {
546 let tmp = TempDir::new().unwrap();
547 let files: Vec<PathBuf> = (0..8)
548 .map(|i| {
549 let f = tmp.path().join(format!("f{i}.py"));
550 fs::write(&f, "x").unwrap();
551 f
552 })
553 .collect();
554
555 let call_sizes = Arc::new(Mutex::new(vec![]));
556 let cs = Arc::clone(&call_sizes);
557 let extractor = move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
558 cs.lock().unwrap().push(chunk.len());
559 let finish = if chunk.len() > 2 { "length" } else { "stop" };
560 Ok(stub_result(chunk.len(), finish))
561 };
562
563 let result = extract_with_adaptive_retry(&files, &extractor, 3, 0);
564 let mut sizes = call_sizes.lock().unwrap().clone();
565 sizes.sort();
566 assert_eq!(sizes, vec![2, 2, 2, 2, 4, 4, 8]);
567 assert_eq!(result.nodes.len(), 8);
568 }
569
570 #[test]
571 fn test_adaptive_retry_caps_at_max_depth() {
572 let tmp = TempDir::new().unwrap();
573 let files: Vec<PathBuf> = (0..8)
574 .map(|i| {
575 let f = tmp.path().join(format!("f{i}.py"));
576 fs::write(&f, "x").unwrap();
577 f
578 })
579 .collect();
580
581 let call_count = Arc::new(Mutex::new(0usize));
582 let cc = Arc::clone(&call_count);
583 let extractor = move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
584 *cc.lock().unwrap() += 1;
585 Ok(stub_result(chunk.len(), "length"))
586 };
587
588 extract_with_adaptive_retry(&files, &extractor, 2, 0);
590 assert!(*call_count.lock().unwrap() <= 7);
591 }
592
593 #[test]
594 fn test_adaptive_retry_single_file_truncation_does_not_recurse() {
595 let tmp = TempDir::new().unwrap();
596 let f = tmp.path().join("huge.py");
597 fs::write(&f, "x").unwrap();
598
599 let call_count = Arc::new(Mutex::new(0usize));
600 let cc = Arc::clone(&call_count);
601 let extractor = move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
602 *cc.lock().unwrap() += 1;
603 Ok(stub_result(chunk.len(), "length"))
604 };
605
606 extract_with_adaptive_retry(&[f], &extractor, 3, 0);
607 assert_eq!(*call_count.lock().unwrap(), 1);
608 }
609
610 #[test]
613 fn test_corpus_parallel_legacy_mode_when_token_budget_is_none() {
614 let tmp = TempDir::new().unwrap();
615 let files: Vec<PathBuf> = (0..45)
616 .map(|i| {
617 let f = tmp.path().join(format!("f{i}.py"));
618 fs::write(&f, "x").unwrap();
619 f
620 })
621 .collect();
622
623 let chunks_seen = Arc::new(Mutex::new(vec![]));
624 let cs = Arc::clone(&chunks_seen);
625
626 let result = extract_corpus_parallel(
627 &files,
628 CorpusParallelConfig {
629 chunk_size: 20,
630 token_budget: None,
631 max_concurrency: 1,
632 max_retry_depth: 3,
633 },
634 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
635 cs.lock().unwrap().push(chunk.len());
636 Ok(stub_result(chunk.len(), "stop"))
637 },
638 None::<fn(usize, usize, &ChunkResult)>,
639 );
640
641 let sizes = chunks_seen.lock().unwrap().clone();
642 assert_eq!(sizes, vec![20, 20, 5]);
643 assert_eq!(result.nodes.len(), 45);
644 }
645
646 #[test]
647 fn test_corpus_parallel_token_budget_default_packs_files() {
648 let tmp = TempDir::new().unwrap();
649 let files: Vec<PathBuf> = (0..50)
650 .map(|i| {
651 let f = tmp.path().join(format!("f{i}.py"));
652 fs::write(&f, "x = 1\n").unwrap();
653 f
654 })
655 .collect();
656
657 let chunks_seen = Arc::new(Mutex::new(vec![]));
658 let cs = Arc::clone(&chunks_seen);
659
660 extract_corpus_parallel(
661 &files,
662 CorpusParallelConfig {
663 token_budget: Some(DEFAULT_TOKEN_BUDGET),
664 max_concurrency: 1,
665 ..Default::default()
666 },
667 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
668 cs.lock().unwrap().push(chunk.len());
669 Ok(stub_result(chunk.len(), "stop"))
670 },
671 None::<fn(usize, usize, &ChunkResult)>,
672 );
673
674 let sizes = chunks_seen.lock().unwrap().clone();
675 assert_eq!(sizes.len(), 1);
676 assert_eq!(sizes[0], 50);
677 }
678
679 #[test]
680 fn test_corpus_parallel_sequential_when_max_concurrency_is_one() {
681 let tmp = TempDir::new().unwrap();
682 let files: Vec<PathBuf> = (0..3)
683 .map(|i| {
684 let f = tmp.path().join(format!("f{i}.py"));
685 fs::write(&f, "x").unwrap();
686 f
687 })
688 .collect();
689
690 let call_order = Arc::new(Mutex::new(vec![]));
691 let co = Arc::clone(&call_order);
692
693 extract_corpus_parallel(
694 &files,
695 CorpusParallelConfig {
696 chunk_size: 1,
697 token_budget: None,
698 max_concurrency: 1,
699 max_retry_depth: 3,
700 },
701 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
702 co.lock()
703 .unwrap()
704 .push(chunk[0].file_name().unwrap().to_string_lossy().to_string());
705 Ok(stub_result(chunk.len(), "stop"))
706 },
707 None::<fn(usize, usize, &ChunkResult)>,
708 );
709
710 let order = call_order.lock().unwrap().clone();
711 assert_eq!(order, vec!["f0.py", "f1.py", "f2.py"]);
712 }
713
714 #[test]
715 fn test_corpus_parallel_continues_after_chunk_failure() {
716 let tmp = TempDir::new().unwrap();
717 let files: Vec<PathBuf> = (0..4)
718 .map(|i| {
719 let f = tmp.path().join(format!("f{i}.py"));
720 fs::write(&f, "x").unwrap();
721 f
722 })
723 .collect();
724
725 let call_count = Arc::new(Mutex::new(0usize));
726 let cc = Arc::clone(&call_count);
727
728 let result = extract_corpus_parallel(
729 &files,
730 CorpusParallelConfig {
731 chunk_size: 1,
732 token_budget: None,
733 max_concurrency: 1,
734 max_retry_depth: 3,
735 },
736 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
737 let n = {
738 let mut c = cc.lock().unwrap();
739 *c += 1;
740 *c
741 };
742 if n == 2 {
743 return Err("simulated API error".to_string());
744 }
745 Ok(stub_result(chunk.len(), "stop"))
746 },
747 None::<fn(usize, usize, &ChunkResult)>,
748 );
749
750 assert_eq!(result.nodes.len(), 3);
752 }
753
754 #[test]
755 fn test_corpus_parallel_uses_adaptive_retry() {
756 let tmp = TempDir::new().unwrap();
757 let files: Vec<PathBuf> = (0..4)
758 .map(|i| {
759 let f = tmp.path().join(format!("f{i}.py"));
760 fs::write(&f, "x").unwrap();
761 f
762 })
763 .collect();
764
765 let call_sizes = Arc::new(Mutex::new(vec![]));
766 let cs = Arc::clone(&call_sizes);
767 let chunk_done_args = Arc::new(Mutex::new(vec![]));
768 let cda = Arc::clone(&chunk_done_args);
769
770 let result = extract_corpus_parallel(
771 &files,
772 CorpusParallelConfig {
773 chunk_size: 4,
774 token_budget: None,
775 max_concurrency: 1,
776 max_retry_depth: 3,
777 },
778 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
779 cs.lock().unwrap().push(chunk.len());
780 let finish = if chunk.len() == 4 { "length" } else { "stop" };
781 Ok(stub_result(chunk.len(), finish))
782 },
783 Some(move |idx: usize, total: usize, r: &ChunkResult| {
784 cda.lock().unwrap().push((idx, total, r.nodes.len()));
785 }),
786 );
787
788 let sizes = call_sizes.lock().unwrap().clone();
789 assert_eq!(sizes, vec![4, 2, 2]);
790 let done = chunk_done_args.lock().unwrap().clone();
791 assert_eq!(done.len(), 1);
792 assert_eq!(done[0], (0, 1, 4));
793 assert_eq!(result.nodes.len(), 4);
794 }
795
796 #[test]
797 fn test_corpus_parallel_runs_chunks_concurrently() {
798 use std::time::Instant;
799
800 let tmp = TempDir::new().unwrap();
801 let files: Vec<PathBuf> = (0..8)
802 .map(|i| {
803 let f = tmp.path().join(format!("f{i}.py"));
804 fs::write(&f, "x").unwrap();
805 f
806 })
807 .collect();
808
809 let t0 = Instant::now();
810 extract_corpus_parallel(
811 &files,
812 CorpusParallelConfig {
813 chunk_size: 2,
814 token_budget: None,
815 max_concurrency: 4,
816 max_retry_depth: 3,
817 },
818 move |chunk: &[PathBuf]| -> Result<ChunkResult, String> {
819 thread::sleep(std::time::Duration::from_millis(300));
820 Ok(stub_result(chunk.len(), "stop"))
821 },
822 None::<fn(usize, usize, &ChunkResult)>,
823 );
824 let elapsed = t0.elapsed().as_secs_f64();
825 assert!(
827 elapsed < 1.0,
828 "expected parallel speedup, took {elapsed:.2}s"
829 );
830 }
831
832 #[test]
835 fn test_failed_chunks_zero_when_all_succeed() {
836 let tmp = TempDir::new().unwrap();
837 let files = make_files(tmp.path(), &["a.py", "b.py", "c.py"], "x = 1\n");
838 let result = extract_corpus_parallel(
839 &files,
840 CorpusParallelConfig {
841 chunk_size: 1,
842 token_budget: None,
843 max_concurrency: 1,
844 max_retry_depth: 0,
845 },
846 |chunk: &[PathBuf]| Ok(stub_result(chunk.len(), "stop")),
847 None::<fn(usize, usize, &ChunkResult)>,
848 );
849 assert_eq!(result.failed_chunks, 0);
850 }
851
852 #[test]
853 fn test_failed_chunks_increments_on_api_error() {
854 let tmp = TempDir::new().unwrap();
855 let files = make_files(tmp.path(), &["a.py", "b.py"], "x = 1\n");
856 let result = extract_corpus_parallel(
857 &files,
858 CorpusParallelConfig {
859 chunk_size: 1,
860 token_budget: None,
861 max_concurrency: 1,
862 max_retry_depth: 0,
863 },
864 |_chunk: &[PathBuf]| Err("simulated API error".to_string()),
865 None::<fn(usize, usize, &ChunkResult)>,
866 );
867 assert!(
868 result.failed_chunks > 0,
869 "expected failed_chunks > 0, got {}",
870 result.failed_chunks
871 );
872 }
873
874 #[test]
875 fn test_failed_chunks_count_matches_multiple_failures() {
876 let tmp = TempDir::new().unwrap();
877 let files: Vec<PathBuf> = (0..4)
878 .map(|i| {
879 let f = tmp.path().join(format!("f{i}.py"));
880 fs::write(&f, "x").unwrap();
881 f
882 })
883 .collect();
884 let call_count = Arc::new(Mutex::new(0usize));
885 let cc = Arc::clone(&call_count);
886 let result = extract_corpus_parallel(
887 &files,
888 CorpusParallelConfig {
889 chunk_size: 1,
890 token_budget: None,
891 max_concurrency: 1,
892 max_retry_depth: 0,
893 },
894 move |chunk: &[PathBuf]| {
895 let n = {
896 let mut c = cc.lock().unwrap();
897 *c += 1;
898 *c
899 };
900 if n % 2 == 0 {
901 Err("api error".to_string())
902 } else {
903 Ok(stub_result(chunk.len(), "stop"))
904 }
905 },
906 None::<fn(usize, usize, &ChunkResult)>,
907 );
908 assert_eq!(result.failed_chunks, 2);
909 assert_eq!(result.nodes.len(), 2);
910 }
911
912 #[test]
913 fn test_failed_chunks_multi_worker() {
914 let tmp = TempDir::new().unwrap();
915 let files: Vec<PathBuf> = (0..6)
916 .map(|i| {
917 let f = tmp.path().join(format!("f{i}.py"));
918 fs::write(&f, "x").unwrap();
919 f
920 })
921 .collect();
922 let result = extract_corpus_parallel(
923 &files,
924 CorpusParallelConfig {
925 chunk_size: 1,
926 token_budget: None,
927 max_concurrency: 3,
928 max_retry_depth: 0,
929 },
930 |_chunk: &[PathBuf]| Err("error".to_string()),
931 None::<fn(usize, usize, &ChunkResult)>,
932 );
933 assert_eq!(result.failed_chunks, 6);
934 assert_eq!(result.nodes.len(), 0);
935 }
936
937 #[test]
938 fn test_chunk_result_merge_accumulates_failed_chunks() {
939 let a = ChunkResult {
940 failed_chunks: 2,
941 ..ChunkResult::empty()
942 };
943 let b = ChunkResult {
944 failed_chunks: 3,
945 ..ChunkResult::empty()
946 };
947 let merged = a.merge(b);
948 assert_eq!(merged.failed_chunks, 5);
949 }
950
951 #[test]
952 fn test_read_chunk_content_utf8_file() {
953 let tmp = TempDir::new().unwrap();
954 let f = tmp.path().join("ok.py");
955 fs::write(&f, "x = 1 # → done\n").unwrap();
956 let content = read_chunk_content(&f);
957 assert!(content.contains("→ done"));
958 }
959
960 #[test]
961 fn test_read_chunk_content_non_utf8_lossy() {
962 let tmp = TempDir::new().unwrap();
963 let f = tmp.path().join("latin1.py");
964 fs::write(&f, b"x = \xe9\n").unwrap();
966 let content = read_chunk_content(&f);
967 assert!(!content.is_empty());
969 content.encode_utf16().for_each(|_| {});
970 }
971
972 #[test]
973 fn test_unicode_content_survives_file_roundtrip() {
974 let tmp = TempDir::new().unwrap();
975 let unicode = "→ means implies. ✅ done. Score ≥ 90.";
976 let f = tmp.path().join("unicode.md");
977 fs::write(&f, unicode.as_bytes()).unwrap();
978 let content = read_chunk_content(&f);
979 assert!(content.contains("→"));
980 assert!(content.contains("✅"));
981 assert!(content.contains("≥"));
982 let _ = content.encode_utf16().collect::<Vec<_>>();
984 assert!(!content.is_empty());
985 }
986
987 #[test]
988 fn test_successful_nodes_present_with_partial_failure() {
989 let tmp = TempDir::new().unwrap();
990 let files: Vec<PathBuf> = (0..3)
991 .map(|i| {
992 let f = tmp.path().join(format!("f{i}.py"));
993 fs::write(&f, "x").unwrap();
994 f
995 })
996 .collect();
997 let call_count = Arc::new(Mutex::new(0usize));
998 let cc = Arc::clone(&call_count);
999 let result = extract_corpus_parallel(
1000 &files,
1001 CorpusParallelConfig {
1002 chunk_size: 1,
1003 token_budget: None,
1004 max_concurrency: 1,
1005 max_retry_depth: 0,
1006 },
1007 move |chunk: &[PathBuf]| {
1008 let n = {
1009 let mut c = cc.lock().unwrap();
1010 *c += 1;
1011 *c
1012 };
1013 if n == 2 {
1014 Err("fail".to_string())
1015 } else {
1016 Ok(stub_result(chunk.len(), "stop"))
1017 }
1018 },
1019 None::<fn(usize, usize, &ChunkResult)>,
1020 );
1021 assert_eq!(result.failed_chunks, 1);
1022 assert_eq!(result.nodes.len(), 2);
1023 }
1024
1025 #[test]
1026 fn test_context_exceeded_error_not_counted_as_failed_chunk() {
1027 let tmp = TempDir::new().unwrap();
1028 let files: Vec<PathBuf> = (0..2)
1029 .map(|i| {
1030 let f = tmp.path().join(format!("f{i}.py"));
1031 fs::write(&f, "x").unwrap();
1032 f
1033 })
1034 .collect();
1035 let result = extract_corpus_parallel(
1036 &files,
1037 CorpusParallelConfig {
1038 chunk_size: 2,
1039 token_budget: None,
1040 max_concurrency: 1,
1041 max_retry_depth: 3,
1042 },
1043 |chunk: &[PathBuf]| {
1044 if chunk.len() > 1 {
1045 Err("context_length_exceeded: too many tokens".to_string())
1046 } else {
1047 Ok(stub_result(chunk.len(), "stop"))
1048 }
1049 },
1050 None::<fn(usize, usize, &ChunkResult)>,
1051 );
1052 assert_eq!(result.failed_chunks, 0);
1054 assert_eq!(result.nodes.len(), 2);
1055 }
1056}