hallouminate 0.2.3

A markdown corpus indexer for LLMs to build and query their own per-repo wikis.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
//! Repository tenant declarations and derived corpora.
//!
//! A `[[repository]]` entry in `config.toml` declares a single git repository
//! that hallouminate can own multiple corpora for: an LLM-managed wiki under
//! `<repo>/.hallouminate/wiki`, and an optional source-document corpus.
//! `repo:{name}:code` is reserved for a future code-aware indexing slice
//! and is not derivable yet.
//!
//! Derived corpora carry the canonical names `repo:{name}:wiki` and
//! `repo:{name}:corpus`. `repo_corpus_name` rejects empty repo names and
//! names containing `':'` so the namespace prefix stays unambiguous.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::domain::common::{CorpusConfig, HallouminateError, Result, expand_tilde};
use crate::domain::corpus::blake3_bytes;

/// Declaration of a single repository tenant.
///
/// `path` is the repository root. `corpus_paths` are document paths the
/// repository wants indexed as a separate source-document corpus; relative
/// entries resolve against `path`. `corpus_globs` / `corpus_exclude`
/// match the `[[corpus]]` semantics.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryConfig {
    pub name: String,
    pub path: String,
    #[serde(default)]
    pub corpus_paths: Vec<String>,
    #[serde(default)]
    pub corpus_globs: Vec<String>,
    #[serde(default)]
    pub corpus_exclude: Vec<String>,
}

/// Kind of repository-derived corpus.
///
/// `Wiki` always exists; `Corpus` exists only when the repository declares
/// `corpus_paths`. `Code` is reserved for a future code-aware slice and is
/// not derivable today — including the variant keeps the namespace explicit
/// without committing to behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoCorpusKind {
    Wiki,
    Corpus,
    // Future: Code maps to repo:{name}:code if code-aware indexing is added.
}

impl RepoCorpusKind {
    fn suffix(self) -> &'static str {
        match self {
            RepoCorpusKind::Wiki => "wiki",
            RepoCorpusKind::Corpus => "corpus",
        }
    }
}

/// Relative path inside the repository where the LLM-managed wiki lives.
pub const WIKI_RELATIVE_PATH: &str = ".hallouminate/wiki";

/// Build the canonical `repo:{name}:{kind}` corpus name.
///
/// Rejects empty names and names containing `':'` — the colon would make the
/// derived name unparseable and let repository tenants collide with the
/// `repo:` namespace prefix.
pub fn repo_corpus_name(repo_name: &str, kind: RepoCorpusKind) -> Result<String> {
    if repo_name.is_empty() {
        return Err(HallouminateError::Config(
            "repository name must not be empty".to_string(),
        ));
    }
    if repo_name.contains(':') {
        return Err(HallouminateError::Config(format!(
            "repository name {repo_name:?} must not contain ':' \
             (reserved as the repo:{{name}}:{{kind}} separator)"
        )));
    }
    Ok(format!("repo:{repo_name}:{}", kind.suffix()))
}

/// Build the derived `repo:{name}:wiki` corpus pointing at
/// `<repo.path>/.hallouminate/wiki`.
///
/// The wiki always exists logically — the daemon creates the directory
/// before the first write or indexing pass.
pub fn repository_wiki_corpus(repo: &RepositoryConfig) -> Result<CorpusConfig> {
    let name = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki)?;
    let wiki_dir = wiki_directory(repo);
    Ok(CorpusConfig {
        name,
        paths: vec![wiki_dir.to_string_lossy().into_owned()],
        globs: vec!["**/*.md".to_string()],
        exclude: Vec::new(),
        global: false,
    })
}

/// Build the derived `repo:{name}:corpus` for repository source documents.
///
/// Returns `None` when the repository declares no `corpus_paths`. Relative
/// paths resolve under `repository.path`; absolute paths are left alone.
pub fn repository_source_corpus(repo: &RepositoryConfig) -> Result<Option<CorpusConfig>> {
    if repo.corpus_paths.is_empty() {
        return Ok(None);
    }
    let name = repo_corpus_name(&repo.name, RepoCorpusKind::Corpus)?;
    let repo_root = PathBuf::from(&repo.path);
    let mut paths: Vec<String> = Vec::with_capacity(repo.corpus_paths.len());
    for raw in &repo.corpus_paths {
        paths.push(resolve_under(&repo_root, raw));
    }
    Ok(Some(CorpusConfig {
        name,
        paths,
        globs: repo.corpus_globs.clone(),
        exclude: repo.corpus_exclude.clone(),
        global: false,
    }))
}

/// All corpora visible to the daemon: explicit `[[corpus]]` entries plus
/// derived repository wiki/source corpora. Rejects duplicate final names so
/// a user-defined corpus cannot shadow a `repo:` derived name.
pub fn effective_corpora(
    corpora: &[CorpusConfig],
    repositories: &[RepositoryConfig],
) -> Result<Vec<CorpusConfig>> {
    let mut out: Vec<CorpusConfig> = corpora.to_vec();
    for repo in repositories {
        out.push(repository_wiki_corpus(repo)?);
        if let Some(src) = repository_source_corpus(repo)? {
            out.push(src);
        }
    }
    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    for corpus in &out {
        if !seen.insert(corpus.name.as_str()) {
            return Err(HallouminateError::Config(format!(
                "duplicate corpus name {:?} after deriving repository corpora",
                corpus.name
            )));
        }
    }
    Ok(out)
}

/// Wiki directory for a repository: `<repo.path>/.hallouminate/wiki`.
pub fn wiki_directory(repo: &RepositoryConfig) -> PathBuf {
    PathBuf::from(&repo.path).join(WIKI_RELATIVE_PATH)
}

/// Pick the default wiki corpus name for `cwd`.
///
/// Returns `repo:{name}:wiki` for the repository whose `path` is the
/// deepest ancestor of `cwd`. Returns `None` when `cwd` does not sit under
/// any configured repository; callers should fall through to the existing
/// single-corpus / ambiguity behavior.
///
/// Tilde and relative segments in `repo.path` are expanded and
/// canonicalized best-effort before the prefix match, so a config that
/// writes `~/Dev/foo` resolves the same as one that writes the absolute
/// equivalent. Repos whose corpus name fails the canonical-name validation
/// (e.g. empty or `:`-bearing) are skipped silently — the per-repo
/// validation surfaces elsewhere.
pub fn default_wiki_for_cwd(repositories: &[RepositoryConfig], cwd: &Path) -> Option<String> {
    let cwd_abs = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
    let mut best: Option<(usize, String)> = None;
    for repo in repositories {
        let expanded = expand_tilde(&repo.path);
        let repo_abs = std::fs::canonicalize(&expanded).unwrap_or(expanded);
        if !cwd_abs.starts_with(&repo_abs) {
            continue;
        }
        let depth = repo_abs.components().count();
        let beats_best = best.as_ref().is_none_or(|(d, _)| depth > *d);
        if !beats_best {
            continue;
        }
        if let Ok(name) = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki) {
            best = Some((depth, name));
        }
    }
    best.map(|(_, name)| name)
}

/// Synthesize a `RepositoryConfig` for a walk-discovered sub-repo wiki (#106).
///
/// `repo_root` is the directory owning the `.hallouminate/`. The derived
/// repository is named after the directory basename and rooted at `repo_root`,
/// so `repository_wiki_corpus` lands the wiki at
/// `<repo_root>/.hallouminate/wiki`. Returns `None` when the basename can't be
/// read or would produce an invalid (`:`-bearing or empty) corpus name — such
/// a repo is skipped silently rather than aborting the union.
pub fn repository_for_discovered_wiki(repo_root: &Path) -> Option<RepositoryConfig> {
    let name = repo_root.file_name()?.to_str()?.to_string();
    if name.trim().is_empty() || name.contains(':') {
        return None;
    }
    Some(RepositoryConfig {
        name,
        path: repo_root.to_string_lossy().into_owned(),
        corpus_paths: Vec::new(),
        corpus_globs: Vec::new(),
        corpus_exclude: Vec::new(),
    })
}

/// Union baseline `[[repository]]` declarations with walk-discovered ones,
/// deduped by resolved wiki path (#106).
///
/// A discovered repo whose wiki directory matches a baseline repo's wiki
/// directory is dropped (the baseline already covers it — no double-count).
///
/// Two kinds of derived-name (`repo:{name}:wiki`) collision at *different*
/// paths are handled distinctly:
///
/// - **Discovered vs. baseline** — the discovered local config the user is
///   sitting above wins; the baseline entry of the same derived name is
///   dropped and a warning names the shadowed baseline repository.
/// - **Discovered vs. discovered** — both are real sub-repo wikis the user
///   asked to union, so dropping either would violate "search the union of
///   **all** discovered wikis". Instead the later repo is kept under a
///   parent-segment-qualified name (e.g. two `tern` siblings become
///   `repo:tern:wiki` and `repo:personal-tern:wiki`) so both wikis survive,
///   and a warning names the colliding sibling rather than a baseline repo.
///
/// Returns the merged repo list (baseline order first, surviving discovered
/// repos appended) plus the collision warnings.
pub fn union_discovered_repositories(
    baseline: &[RepositoryConfig],
    discovered: Vec<RepositoryConfig>,
) -> (Vec<RepositoryConfig>, Vec<String>) {
    let baseline_wiki_paths: std::collections::HashSet<PathBuf> =
        baseline.iter().map(canonical_wiki_path).collect();
    let mut out: Vec<RepositoryConfig> = baseline.to_vec();
    let mut warnings: Vec<String> = Vec::new();
    for mut repo in discovered {
        // Dedupe by resolved wiki path: a baseline repo living below cwd is
        // already represented, so skip the discovered duplicate.
        if baseline_wiki_paths.contains(&canonical_wiki_path(&repo)) {
            continue;
        }
        let Ok(name) = repo_corpus_name(&repo.name, RepoCorpusKind::Wiki) else {
            continue;
        };
        if let Some(pos) = position_by_derived_name(&out, &name) {
            // Decide baseline-vs-discovered by the matched entry's wiki path,
            // not by a frozen index boundary: `out.remove` below shifts indices,
            // so an index captured once would mis-classify a later sibling after
            // any baseline removal (re-opening the silent-drop bug this cure
            // closed). Membership in `baseline_wiki_paths` is removal-stable.
            let matched_is_baseline = baseline_wiki_paths.contains(&canonical_wiki_path(&out[pos]));
            if matched_is_baseline {
                // Discovered vs. baseline: prefer the discovered local repo and
                // drop the baseline entry of the same derived name.
                warnings.push(format!(
                    "discovered sub-repo wiki {name:?} shadows a baseline repository \
                     of the same name; preferring the discovered local config",
                ));
                out.remove(pos);
            } else {
                // Discovered vs. discovered: both are real wikis the union must
                // keep, so disambiguate the later repo's name instead of
                // dropping it — silently losing one would violate "union of all
                // discovered wikis".
                let new_name = disambiguate_discovered_name(&mut repo, &out);
                warnings.push(format!(
                    "discovered sub-repo wiki {name:?} (at {path:?}) collides with another \
                     discovered sub-repo of the same name; keeping both, renaming this one to \
                     {new_name:?} so its wiki is not dropped",
                    path = repo.path,
                ));
            }
        }
        out.push(repo);
    }
    (out, warnings)
}

/// Index in `repos` of the first entry whose derived `repo:{name}:wiki` name
/// equals `derived_name`, if any.
fn position_by_derived_name(repos: &[RepositoryConfig], derived_name: &str) -> Option<usize> {
    repos.iter().position(|r| {
        repo_corpus_name(&r.name, RepoCorpusKind::Wiki)
            .ok()
            .as_deref()
            == Some(derived_name)
    })
}

/// Rename a discovered repo so its derived wiki name no longer collides with
/// any entry already in `existing`, and return the new derived corpus name.
///
/// Qualifies the basename with parent directory segments (`tern` →
/// `personal-tern` → `dev-personal-tern` → …), walking up one segment at a
/// time until the derived name is unique. Segments bearing `':'` are sanitized
/// to `-` so the qualified name still passes `repo_corpus_name`.
///
/// When no parent segment disambiguates (an empty-segment path, or every
/// qualified candidate re-collides), it appends a deterministic short hash of
/// the repo path (`tern` → `tern-3f9a2b1c`) and bumps the digest length until
/// the name is unique. This *guarantees* a unique, valid name on return —
/// pushing a duplicate would trip `effective_corpora`'s dup-name guard and
/// error-abort the whole ground request (spec: do not error-abort on
/// collision). `repo.name` is always mutated to the returned base name.
fn disambiguate_discovered_name(
    repo: &mut RepositoryConfig,
    existing: &[RepositoryConfig],
) -> String {
    let base = repo.name.clone();
    let segments: Vec<String> = Path::new(&repo.path)
        .parent()
        .map(|parent| {
            parent
                .components()
                .filter_map(|c| match c {
                    std::path::Component::Normal(s) => Some(s.to_string_lossy().replace(':', "-")),
                    _ => None,
                })
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default();
    for take in 1..=segments.len() {
        let prefix = segments[segments.len() - take..].join("-");
        let candidate = format!("{prefix}-{base}");
        if let Some(derived) = unique_derived_name(&candidate, existing) {
            repo.name = candidate;
            return derived;
        }
    }
    // No parent segment disambiguated. Append a deterministic short path hash
    // and lengthen it until unique, so the union always keeps both wikis under
    // distinct names rather than pushing a duplicate that aborts the request.
    let digest = blake3_bytes(repo.path.as_bytes());
    for len in 8..=digest.len() {
        let candidate = format!("{base}-{}", &digest[..len]);
        if let Some(derived) = unique_derived_name(&candidate, existing) {
            repo.name = candidate;
            return derived;
        }
    }
    // Unreachable in practice (a full 64-hex BLAKE3 collision against an
    // existing name), but keep both wikis rather than panicking: fall back to
    // the full-digest candidate.
    let candidate = format!("{base}-{digest}");
    let derived = repo_corpus_name(&candidate, RepoCorpusKind::Wiki).unwrap_or(base);
    repo.name = candidate;
    derived
}

/// Derive the `repo:{candidate}:wiki` name for `candidate` and return it only
/// if it is valid and collides with no entry already in `existing`.
fn unique_derived_name(candidate: &str, existing: &[RepositoryConfig]) -> Option<String> {
    let derived = repo_corpus_name(candidate, RepoCorpusKind::Wiki).ok()?;
    position_by_derived_name(existing, &derived)
        .is_none()
        .then_some(derived)
}

/// Canonicalized wiki directory for dedupe comparison. Best-effort: tilde is
/// expanded and the path canonicalized, falling back to the raw join when the
/// directory doesn't yet exist (an unindexed wiki) so two configs pointing at
/// the same logical wiki still compare equal.
fn canonical_wiki_path(repo: &RepositoryConfig) -> PathBuf {
    let wiki = wiki_directory(repo);
    let expanded = expand_tilde(&wiki.to_string_lossy());
    std::fs::canonicalize(&expanded).unwrap_or(expanded)
}

fn resolve_under(base: &Path, raw: &str) -> String {
    let candidate = Path::new(raw);
    if candidate.is_absolute() {
        raw.to_string()
    } else {
        base.join(candidate).to_string_lossy().into_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn repo(name: &str, path: &str) -> RepositoryConfig {
        RepositoryConfig {
            name: name.into(),
            path: path.into(),
            corpus_paths: Vec::new(),
            corpus_globs: Vec::new(),
            corpus_exclude: Vec::new(),
        }
    }

    #[test]
    fn repo_corpus_name_emits_canonical_wiki_and_corpus_suffixes() {
        assert_eq!(
            repo_corpus_name("tern", RepoCorpusKind::Wiki).unwrap(),
            "repo:tern:wiki",
        );
        assert_eq!(
            repo_corpus_name("tern", RepoCorpusKind::Corpus).unwrap(),
            "repo:tern:corpus",
        );
    }

    #[test]
    fn repo_corpus_name_rejects_empty_name() {
        let err = repo_corpus_name("", RepoCorpusKind::Wiki).expect_err("empty must fail");
        match err {
            HallouminateError::Config(msg) => assert!(msg.contains("empty"), "got: {msg}"),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn repo_corpus_name_rejects_names_containing_colon() {
        // `:` is the namespace separator; allowing it would let a repo
        // declare itself as `tern:wiki` and clash with the derived suffix.
        let err = repo_corpus_name("a:b", RepoCorpusKind::Wiki).expect_err("colon must fail");
        match err {
            HallouminateError::Config(msg) => {
                assert!(msg.contains("':'") || msg.contains("colon"), "got: {msg}");
                assert!(msg.contains("a:b"), "got: {msg}");
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn repository_wiki_corpus_anchors_under_dot_hallouminate_wiki() {
        let cfg = repository_wiki_corpus(&repo("tern", "/repos/tern")).unwrap();
        assert_eq!(cfg.name, "repo:tern:wiki");
        assert_eq!(
            cfg.paths,
            vec!["/repos/tern/.hallouminate/wiki".to_string()],
        );
        assert_eq!(cfg.globs, vec!["**/*.md".to_string()]);
        assert!(cfg.exclude.is_empty());
    }

    #[test]
    fn repository_source_corpus_returns_none_when_corpus_paths_empty() {
        let cfg = repository_source_corpus(&repo("tern", "/r")).unwrap();
        assert!(cfg.is_none(), "no corpus_paths => no source corpus");
    }

    #[test]
    fn repository_source_corpus_resolves_relative_paths_against_repo_path() {
        let mut r = repo("tern", "/repos/tern");
        r.corpus_paths = vec!["docs".into(), "/abs/elsewhere".into()];
        r.corpus_globs = vec!["**/*.md".into()];
        r.corpus_exclude = vec!["**/drafts/**".into()];
        let cfg = repository_source_corpus(&r).unwrap().expect("present");
        assert_eq!(cfg.name, "repo:tern:corpus");
        assert_eq!(
            cfg.paths,
            vec!["/repos/tern/docs".to_string(), "/abs/elsewhere".to_string(),],
        );
        assert_eq!(cfg.globs, vec!["**/*.md".to_string()]);
        assert_eq!(cfg.exclude, vec!["**/drafts/**".to_string()]);
    }

    #[test]
    fn effective_corpora_appends_derived_repository_corpora() {
        let user = CorpusConfig {
            name: "docs".into(),
            paths: vec!["/docs".into()],
            globs: vec!["**/*.md".into()],
            exclude: Vec::new(),
            global: false,
        };
        let mut r = repo("tern", "/r");
        r.corpus_paths = vec!["src/docs".into()];
        let all = effective_corpora(std::slice::from_ref(&user), &[r]).unwrap();
        let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(names, vec!["docs", "repo:tern:wiki", "repo:tern:corpus"]);
    }

    #[test]
    fn effective_corpora_rejects_user_corpus_colliding_with_derived_name() {
        let shadow = CorpusConfig {
            name: "repo:tern:wiki".into(),
            paths: vec!["/x".into()],
            ..Default::default()
        };
        let r = repo("tern", "/r");
        let err = effective_corpora(&[shadow], &[r]).expect_err("duplicate must fail");
        match err {
            HallouminateError::Config(msg) => {
                assert!(msg.contains("duplicate"), "got: {msg}");
                assert!(msg.contains("repo:tern:wiki"), "got: {msg}");
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn effective_corpora_omits_source_corpus_when_repo_declares_no_paths() {
        let r = repo("tern", "/r");
        let all = effective_corpora(&[], &[r]).unwrap();
        let names: Vec<&str> = all.iter().map(|c| c.name.as_str()).collect();
        assert_eq!(names, vec!["repo:tern:wiki"]);
    }

    #[test]
    fn wiki_directory_is_repo_path_joined_with_dot_hallouminate_wiki() {
        let r = repo("tern", "/repos/tern");
        assert_eq!(
            wiki_directory(&r),
            PathBuf::from("/repos/tern/.hallouminate/wiki"),
        );
    }

    // ── default_wiki_for_cwd ──────────────────────────────────────────────

    #[test]
    fn default_wiki_for_cwd_returns_none_with_no_repositories() {
        let tmp = tempfile::tempdir().expect("tempdir");
        assert!(default_wiki_for_cwd(&[], tmp.path()).is_none());
    }

    #[test]
    fn default_wiki_for_cwd_returns_none_when_cwd_outside_every_repo() {
        let outer = tempfile::tempdir().expect("tempdir");
        let repo_root = outer.path().join("inside");
        std::fs::create_dir(&repo_root).expect("mkdir");
        let elsewhere = outer.path().join("elsewhere");
        std::fs::create_dir(&elsewhere).expect("mkdir");
        let r = repo("tern", repo_root.to_str().unwrap());
        assert!(default_wiki_for_cwd(&[r], &elsewhere).is_none());
    }

    #[test]
    fn default_wiki_for_cwd_picks_repo_containing_cwd() {
        let outer = tempfile::tempdir().expect("tempdir");
        let repo_root = outer.path().join("tern");
        let nested = repo_root.join("src");
        std::fs::create_dir_all(&nested).expect("mkdir");
        let r = repo("tern", repo_root.to_str().unwrap());
        let got = default_wiki_for_cwd(&[r], &nested).expect("matched");
        assert_eq!(got, "repo:tern:wiki");
    }

    #[test]
    fn default_wiki_for_cwd_prefers_deepest_repo_when_nested() {
        // When two repos are configured and one's path is inside the other,
        // cwd that lies inside both should resolve to the deeper repo's wiki
        // — that's the wiki the LLM is actually working in.
        let outer = tempfile::tempdir().expect("tempdir");
        let parent_repo = outer.path().join("parent");
        let inner_repo = parent_repo.join("vendor").join("inner");
        let cwd = inner_repo.join("src");
        std::fs::create_dir_all(&cwd).expect("mkdir");
        let parent = repo("parent", parent_repo.to_str().unwrap());
        let inner = repo("inner", inner_repo.to_str().unwrap());
        let got = default_wiki_for_cwd(&[parent, inner], &cwd).expect("matched");
        assert_eq!(got, "repo:inner:wiki");
    }

    // ── discovery union (#106) ────────────────────────────────────────────

    #[test]
    fn repository_for_discovered_wiki_names_repo_after_directory_basename() {
        let r = repository_for_discovered_wiki(Path::new("/home/dev/tern")).expect("derived");
        assert_eq!(r.name, "tern");
        assert_eq!(r.path, "/home/dev/tern");
        assert_eq!(
            repository_wiki_corpus(&r).unwrap().name,
            "repo:tern:wiki",
            "discovered repo derives the standard repo:{{name}}:wiki corpus"
        );
    }

    #[test]
    fn repository_for_discovered_wiki_skips_invalid_basename() {
        // A basename bearing the namespace separator can't produce a valid
        // corpus name, so the discovered repo is dropped rather than aborting.
        assert!(
            repository_for_discovered_wiki(Path::new("/home/dev/a:b")).is_none(),
            "':'-bearing basename must yield None"
        );
    }

    #[test]
    fn repository_for_discovered_wiki_skips_whitespace_only_basename() {
        // A sibling dir whose basename is whitespace-only (e.g. created by an
        // unusual filesystem or symlink) passes the raw `is_empty()` check but
        // fails `validate` which calls `trim().is_empty()`. The fix trims before
        // the check so such a repo is dropped silently rather than hard-erroring
        // the whole union resolve path.
        //
        // PathBuf lets us construct a path ending in a whitespace-only segment
        // by pushing the literal segment onto an existing prefix.
        let mut p = std::path::PathBuf::from("/home/dev");
        p.push("   "); // whitespace-only basename
        assert!(
            repository_for_discovered_wiki(&p).is_none(),
            "whitespace-only basename must yield None"
        );
    }

    #[test]
    fn union_appends_discovered_repos_after_baseline_with_no_collision() {
        let baseline = vec![repo("alpha", "/dev/alpha")];
        let discovered = vec![repo("beta", "/dev/beta"), repo("gamma", "/dev/gamma")];
        let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
        let names: Vec<&str> = merged.iter().map(|r| r.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["alpha", "beta", "gamma"],
            "baseline first, discovered appended"
        );
        assert!(warnings.is_empty(), "no collision => no warnings");
    }

    #[test]
    fn union_dedupes_discovered_repo_sharing_a_baseline_wiki_path() {
        // A baseline repo living below cwd is rediscovered by the walk. The
        // discovered duplicate shares the baseline's wiki path and must be
        // dropped — no double-counting, even though their names differ.
        let baseline = vec![repo("registered", "/dev/shared")];
        let discovered = vec![repo("shared", "/dev/shared")];
        let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
        let names: Vec<&str> = merged.iter().map(|r| r.name.as_str()).collect();
        assert_eq!(
            names,
            vec!["registered"],
            "same wiki path => discovered duplicate dropped"
        );
        assert!(
            warnings.is_empty(),
            "path-dedupe is silent (not a shadowing collision)"
        );
    }

    #[test]
    fn union_prefers_discovered_on_name_collision_at_different_path_and_warns() {
        // Same derived corpus name (`repo:tern:wiki`) but different paths: the
        // local discovered config wins over the baseline, with a warning.
        let baseline = vec![repo("tern", "/baseline/tern")];
        let discovered = vec![repo("tern", "/local/tern")];
        let (merged, warnings) = union_discovered_repositories(&baseline, discovered);
        assert_eq!(merged.len(), 1, "collision collapses to one entry");
        assert_eq!(
            merged[0].path, "/local/tern",
            "discovered local config is preferred over baseline"
        );
        assert_eq!(warnings.len(), 1, "shadowing must warn exactly once");
        assert!(
            warnings[0].contains("repo:tern:wiki") && warnings[0].contains("shadows"),
            "warning must name the shadowed corpus: {warnings:?}"
        );
    }

    #[test]
    fn union_keeps_both_same_basename_discovered_siblings_with_distinct_corpora() {
        // Two discovered sub-repos share a basename (`tern`) at different paths
        // — e.g. `~/Dev/work/tern` and `~/Dev/personal/tern`. The union must
        // keep BOTH wikis (the headline acceptance criterion: "search the union
        // of ALL discovered wikis"); the earlier code silently dropped one.
        let baseline: Vec<RepositoryConfig> = Vec::new();
        let discovered = vec![
            repo("tern", "/dev/work/tern"),
            repo("tern", "/dev/personal/tern"),
        ];
        let (merged, _warnings) = union_discovered_repositories(&baseline, discovered);
        assert_eq!(
            merged.len(),
            2,
            "both same-basename siblings must survive the union, not one"
        );
        // Both wiki paths are preserved — neither repo's wiki dir is dropped.
        let paths: Vec<&str> = merged.iter().map(|r| r.path.as_str()).collect();
        assert!(
            paths.contains(&"/dev/work/tern") && paths.contains(&"/dev/personal/tern"),
            "both wiki roots must be present: {paths:?}"
        );
        // Derived corpus names must be distinct so `effective_corpora`'s
        // duplicate-name guard does not collapse or reject the pair.
        let mut corpora: Vec<String> = merged
            .iter()
            .map(|r| repo_corpus_name(&r.name, RepoCorpusKind::Wiki).unwrap())
            .collect();
        corpora.sort();
        corpora.dedup();
        assert_eq!(
            corpora.len(),
            2,
            "the two siblings must derive distinct corpus names: {corpora:?}"
        );
        assert!(
            corpora.iter().any(|c| c == "repo:tern:wiki"),
            "first sibling keeps the canonical name: {corpora:?}"
        );
        assert!(
            corpora
                .iter()
                .any(|c| c.contains("tern") && c != "repo:tern:wiki"),
            "second sibling is parent-qualified: {corpora:?}"
        );
    }

    #[test]
    fn union_warning_for_discovered_sibling_collision_names_sibling_not_baseline() {
        // With NO baseline, a same-basename collision is between two discovered
        // repos. The warning must describe that accurately — not claim the wiki
        // "shadows a baseline repository", which would point the user at a
        // cause that does not exist in the all-discovered case.
        let baseline: Vec<RepositoryConfig> = Vec::new();
        let discovered = vec![
            repo("tern", "/dev/work/tern"),
            repo("tern", "/dev/personal/tern"),
        ];
        let (_merged, warnings) = union_discovered_repositories(&baseline, discovered);
        assert_eq!(warnings.len(), 1, "one collision => one warning");
        let w = &warnings[0];
        assert!(
            w.contains("another discovered sub-repo"),
            "warning must name the colliding discovered sibling: {w:?}"
        );
        assert!(
            !w.contains("baseline"),
            "all-discovered collision must NOT misdescribe the cause as a baseline shadow: {w:?}"
        );
        assert!(
            w.contains("keeping both"),
            "warning must state that both wikis are kept, not that one was dropped: {w:?}"
        );
    }

    #[test]
    fn union_baseline_tern_plus_two_discovered_tern_siblings_keeps_all_three_wikis() {
        // Regression for the stale-`baseline_count` boundary: a baseline repo
        // whose derived name collides with TWO discovered siblings. Iter 1
        // removes the baseline `tern` (shifting indices); iter 2 must still
        // recognize the surviving entry as discovered — not mis-branch it as
        // baseline and silently drop it with a misleading shadow warning.
        let baseline = vec![repo("tern", "/baseline/tern")];
        let discovered = vec![
            repo("tern", "/dev/work/tern"),
            repo("tern", "/dev/personal/tern"),
        ];
        let (merged, warnings) = union_discovered_repositories(&baseline, discovered);

        // Both discovered wikis survive (the baseline is the one shadowed/dropped).
        let paths: Vec<&str> = merged.iter().map(|r| r.path.as_str()).collect();
        assert!(
            paths.contains(&"/dev/work/tern") && paths.contains(&"/dev/personal/tern"),
            "both discovered sibling wikis must survive, not be dropped: {paths:?}"
        );
        assert!(
            !paths.contains(&"/baseline/tern"),
            "the baseline `tern` is the entry shadowed by the discovered configs: {paths:?}"
        );

        // Derived corpus names must all be distinct so `effective_corpora`'s
        // dup-name guard does not reject the set (the abort path).
        effective_corpora(&[], &merged)
            .expect("merged repos must derive a dup-free corpus set, not error-abort");

        // Exactly one baseline-shadow warning (for the baseline), and the
        // sibling collision is described as a discovered-vs-discovered rename,
        // never a second silent drop with a baseline-shadow warning.
        let shadow_warnings = warnings.iter().filter(|w| w.contains("shadows")).count();
        assert_eq!(
            shadow_warnings, 1,
            "only the genuine baseline shadow warns about shadowing: {warnings:?}"
        );
        assert!(
            warnings.iter().any(|w| w.contains("keeping both")),
            "the discovered sibling collision must warn it kept both wikis: {warnings:?}"
        );
    }

    #[test]
    fn union_fallback_when_parent_segments_exhausted_still_keeps_both_with_unique_name() {
        // A bare-basename path has no `Normal` parent segments to qualify with,
        // so parent-segment disambiguation is exhausted on the second sibling.
        // The fallback must still land a unique name (hash suffix) — never push
        // a duplicate that trips `effective_corpora`'s dup-name guard and aborts
        // the whole ground request (spec line 42: do not error-abort).
        let baseline: Vec<RepositoryConfig> = Vec::new();
        let discovered = vec![repo("tern", "tern"), repo("tern", "tern")];
        let (merged, warnings) = union_discovered_repositories(&baseline, discovered);

        assert_eq!(merged.len(), 2, "both bare-path siblings must be kept");
        let corpora =
            effective_corpora(&[], &merged).expect("fallback must not produce a duplicate name");
        let mut names: Vec<&str> = corpora.iter().map(|c| c.name.as_str()).collect();
        names.sort_unstable();
        let unique = names.len();
        names.dedup();
        assert_eq!(
            names.len(),
            unique,
            "derived corpus names must be unique: {names:?}"
        );
        let renamed = &warnings[0];
        assert!(
            renamed.contains("keeping both") && !renamed.contains("repo:tern:wiki\" so"),
            "warning must name the actual unique new name, not the original: {renamed:?}"
        );
    }

    #[test]
    fn union_fallback_when_qualified_candidate_recollides_lands_a_unique_name() {
        // The first parent-segment candidate for `/x/personal/tern` is
        // `personal-tern`, which already exists as a discovered repo. The
        // disambiguator must keep climbing (or hash-suffix) to a unique name
        // rather than re-colliding and pushing a duplicate.
        let baseline: Vec<RepositoryConfig> = Vec::new();
        let discovered = vec![
            repo("tern", "/x/work/tern"),
            repo("personal-tern", "/x/elsewhere/personal-tern"),
            repo("tern", "/x/personal/tern"),
        ];
        let (merged, _warnings) = union_discovered_repositories(&baseline, discovered);
        assert_eq!(merged.len(), 3, "all three distinct wikis must be kept");
        let corpora = effective_corpora(&[], &merged)
            .expect("a re-colliding qualified candidate must not abort the union");
        let mut names: Vec<&str> = corpora.iter().map(|c| c.name.as_str()).collect();
        let total = names.len();
        names.sort_unstable();
        names.dedup();
        assert_eq!(
            names.len(),
            total,
            "every derived corpus name must be unique: {names:?}"
        );
    }
}