Skip to main content

codesynapse_core/
chunking.rs

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    // ---- estimate_file_tokens ------------------------------------------------
343
344    #[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        // 1000 bytes (capped at 20000) + 80 overhead = 1080 / 4 = 270
350        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        // capped at 20000 + 80 = 20080 / 4 = 5020
364        assert_eq!(estimate_file_tokens(&f), 5020);
365    }
366
367    // ---- pack_chunks_by_tokens -----------------------------------------------
368
369    #[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        // Each 10000-char file: (10000+80)/4 = 2520 tokens.
395        // Budget 6000 fits 2 (5040 < 6000) but not 3 (7560 > 6000).
396        // 5 files → [2, 2, 1]
397        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    // ---- extract_with_adaptive_retry ----------------------------------------
478
479    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        // max_depth=2 bounds: root(1) + 2 halves + 4 quarters = 7 max
589        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    // ---- extract_corpus_parallel --------------------------------------------
611
612    #[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        // 4 chunks, 1 failed → 3 chunks contributed 1 node each
751        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        // 4 chunks × 300ms sequential = 1.2s; parallel ≤ 4 workers ≈ 0.3-0.6s
826        assert!(
827            elapsed < 1.0,
828            "expected parallel speedup, took {elapsed:.2}s"
829        );
830    }
831
832    // ---- failed_chunks tracking (parity: test_charmap_encoding.py) ----------
833
834    #[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        // Write bytes that are valid latin-1 but not valid UTF-8 (0xe9 = é in latin-1)
965        fs::write(&f, b"x = \xe9\n").unwrap();
966        let content = read_chunk_content(&f);
967        // Must not panic; must be valid UTF-8 string
968        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        // Must encode cleanly to UTF-8
983        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        // Context-exceeded triggers split+retry, not failed_chunks increment
1053        assert_eq!(result.failed_chunks, 0);
1054        assert_eq!(result.nodes.len(), 2);
1055    }
1056}