Skip to main content

aicx_parser/
segmentation.rs

1//! Semantic segmentation for canonical store ownership.
2//!
3//! Reconstructs repository-scoped session segments from content signals rather
4//! than weak source-side identifiers.
5//!
6//! Vibecrafted with AI Agents by Vetcoders (c)2026 Vetcoders
7
8use crate::sanitize;
9use crate::timeline::{Kind, RepoIdentity, SemanticSegment, SourceTier, TimelineEntry};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15// ============================================================================
16// Source trust model
17// ============================================================================
18
19/// A repo identity paired with the trust tier of the signal that produced it.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TieredIdentity {
22    pub identity: RepoIdentity,
23    pub tier: SourceTier,
24}
25
26/// Explicit source used when assigning an entry/session to a bucket.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum BucketingSource {
29    OperatorOverride,
30    CwdGitRemote,
31    CwdGitRoot,
32    KnownLayout,
33    Frontmatter,
34    ContentMention,
35    Unclassified,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct BucketResolution {
40    pub bucket: String,
41    pub source: BucketingSource,
42    pub identity: Option<RepoIdentity>,
43}
44
45// ============================================================================
46// Gemini projectHash registry
47// ============================================================================
48
49/// Registry mapping Gemini `projectHash` values to known repo roots.
50///
51/// The mapping lives in `~/.aicx/gemini-project-map.json` and must be
52/// maintained by the user or by `aicx init`. A projectHash that is not
53/// in this file cannot resolve to a repo — it stays Opaque.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct ProjectHashRegistry {
56    /// Maps `projectHash` (hex string) → absolute path to project root.
57    #[serde(default)]
58    pub mappings: HashMap<String, String>,
59}
60
61impl ProjectHashRegistry {
62    /// Load from the default location. Honors `AICX_HOME` (operator's
63    /// explicit store-root override) before falling back to
64    /// `~/.aicx/gemini-project-map.json`. Returns an empty registry if
65    /// the file doesn't exist or can't be parsed — callers can rely on
66    /// this method without an existence check.
67    pub fn load_default() -> Self {
68        let base = std::env::var_os("AICX_HOME")
69            .map(PathBuf::from)
70            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".aicx")));
71        let Some(base) = base else {
72            return Self::default();
73        };
74        let path = base.join("gemini-project-map.json");
75        Self::load_from(&path)
76    }
77
78    /// Load from a specific path.
79    pub fn load_from(path: &Path) -> Self {
80        sanitize::read_to_string_validated(path)
81            .ok()
82            .and_then(|content| serde_json::from_str(&content).ok())
83            .unwrap_or_default()
84    }
85
86    /// Resolve a projectHash to a `TieredIdentity` by looking up the mapped
87    /// path and then inferring repo identity from that path.
88    pub fn resolve(&self, project_hash: &str) -> Option<TieredIdentity> {
89        let root_path = self.mappings.get(project_hash)?;
90        let path = PathBuf::from(root_path);
91        let identity = infer_repo_identity_from_path(&path)?;
92        Some(TieredIdentity {
93            identity,
94            tier: SourceTier::Secondary,
95        })
96    }
97}
98
99pub fn semantic_segments(entries: &[TimelineEntry]) -> Vec<SemanticSegment> {
100    // Load the project-hash registry from disk (AICX_HOME or ~/.aicx),
101    // not the empty `Default`. Without this, Gemini sessions whose
102    // identity arrives as a `projectHash` always resolved to Opaque
103    // even when the operator's `gemini-project-map.json` was sitting
104    // right there — the helper existed but no hot path called it.
105    semantic_segments_with_registry(entries, &ProjectHashRegistry::load_default())
106}
107
108/// Same as [`semantic_segments`] but emits per-session cumulative
109/// entry-processed counts to `progress`, so callers can pin a
110/// `Heartbeat` floor to real work done (rather than ticking blind).
111/// Pass-4 follow-up: the segment phase moved AHEAD of dedup, so for
112/// large corpora the progress bar needs a real denominator to stay
113/// honest.
114pub fn semantic_segments_with_progress(
115    entries: &[TimelineEntry],
116    progress: impl FnMut(usize),
117) -> Vec<SemanticSegment> {
118    semantic_segments_with_registry_and_progress(
119        entries,
120        &ProjectHashRegistry::load_default(),
121        progress,
122    )
123}
124
125pub fn semantic_segments_with_registry(
126    entries: &[TimelineEntry],
127    registry: &ProjectHashRegistry,
128) -> Vec<SemanticSegment> {
129    semantic_segments_with_registry_and_progress(entries, registry, |_| {})
130}
131
132pub fn semantic_segments_with_registry_and_progress(
133    entries: &[TimelineEntry],
134    registry: &ProjectHashRegistry,
135    mut progress: impl FnMut(usize),
136) -> Vec<SemanticSegment> {
137    let mut sessions: HashMap<(String, String), Vec<TimelineEntry>> = HashMap::new();
138    for entry in entries {
139        sessions
140            .entry((entry.agent.clone(), entry.session_id.clone()))
141            .or_default()
142            .push(entry.clone());
143    }
144
145    let mut ordered = Vec::new();
146    let mut processed_entries: usize = 0;
147
148    for ((agent, session_id), mut session_entries) in sessions {
149        let session_len = session_entries.len();
150        session_entries.sort_by_key(|left| left.timestamp);
151
152        let mut current_tiered: Option<TieredIdentity> = None;
153        let mut current_entries: Vec<TimelineEntry> = Vec::new();
154
155        for entry in session_entries {
156            let explicit = infer_tiered_identity_from_entry(&entry, registry);
157
158            let explicit_repo = explicit.as_ref().map(|t| &t.identity);
159            let current_repo = current_tiered.as_ref().map(|t| &t.identity);
160
161            let split_for_first_truth =
162                !current_entries.is_empty() && current_repo.is_none() && explicit_repo.is_some();
163            let split_for_context_switch = !current_entries.is_empty()
164                && explicit_repo
165                    .zip(current_repo)
166                    .is_some_and(|(next_repo, active_repo)| next_repo != active_repo);
167
168            if split_for_first_truth || split_for_context_switch {
169                let tier = current_tiered.as_ref().map(|t| t.tier);
170                ordered.push(build_segment(
171                    current_tiered.take().map(|t| t.identity),
172                    tier,
173                    &agent,
174                    &session_id,
175                    std::mem::take(&mut current_entries),
176                ));
177            }
178
179            if current_entries.is_empty() {
180                current_tiered = explicit.clone();
181            }
182
183            if current_tiered.is_none() && explicit.is_some() {
184                current_tiered = explicit.clone();
185            }
186
187            current_entries.push(entry);
188        }
189
190        if !current_entries.is_empty() {
191            let tier = current_tiered.as_ref().map(|t| t.tier);
192            ordered.push(build_segment(
193                current_tiered.map(|t| t.identity),
194                tier,
195                &agent,
196                &session_id,
197                current_entries,
198            ));
199        }
200
201        processed_entries += session_len;
202        progress(processed_entries);
203    }
204
205    ordered.sort_by(|left, right| {
206        left.entries
207            .first()
208            .map(|entry| entry.timestamp)
209            .cmp(&right.entries.first().map(|entry| entry.timestamp))
210            .then_with(|| left.agent.cmp(&right.agent))
211            .then_with(|| left.session_id.cmp(&right.session_id))
212    });
213
214    ordered
215}
216
217pub fn infer_repo_identity_from_entry(entry: &TimelineEntry) -> Option<RepoIdentity> {
218    infer_tiered_identity_from_entry(entry, &ProjectHashRegistry::default()).map(|t| t.identity)
219}
220
221pub fn resolve_bucket(entry: &TimelineEntry, registry: &ProjectHashRegistry) -> BucketResolution {
222    if let Some(tiered) = infer_tiered_identity_from_cwd(entry.cwd.as_deref()) {
223        let source = match tiered.tier {
224            SourceTier::Primary => BucketingSource::CwdGitRemote,
225            SourceTier::Secondary => BucketingSource::CwdGitRoot,
226            SourceTier::Fallback => BucketingSource::KnownLayout,
227            SourceTier::Opaque => BucketingSource::Unclassified,
228        };
229        return BucketResolution {
230            bucket: tiered.identity.slug(),
231            source,
232            identity: Some(tiered.identity),
233        };
234    }
235
236    if let Some(cwd) = entry.cwd.as_deref()
237        && looks_like_weak_source_identifier(cwd)
238        && let Some(tiered) = registry.resolve(cwd)
239    {
240        return BucketResolution {
241            bucket: tiered.identity.slug(),
242            source: BucketingSource::KnownLayout,
243            identity: Some(tiered.identity),
244        };
245    }
246
247    if let Some(tiered) = infer_tiered_identity_from_text(&entry.message) {
248        return BucketResolution {
249            bucket: tiered.identity.slug(),
250            source: BucketingSource::ContentMention,
251            identity: Some(tiered.identity),
252        };
253    }
254
255    BucketResolution {
256        bucket: "unclassified".to_string(),
257        source: BucketingSource::Unclassified,
258        identity: None,
259    }
260}
261
262/// Infer repo identity with explicit trust tier from source-side signals.
263///
264/// Signal precedence (highest to lowest):
265/// 1. CWD that resolves via local git + remote -> Primary
266/// 2. CWD that resolves via local git + known layout/basename -> Secondary
267/// 3. CWD via known layout (no .git) -> Fallback
268/// 4. ProjectHash resolved through registry (Gemini) -> Secondary
269///
270/// Text mentions are deliberately NOT a fallback here. A chunk may legitimately
271/// quote URLs or paths from other repos as discussion material — promoting any
272/// of them to entry identity would (a) split a single-owner session across
273/// `current_repo != explicit_repo` segments and (b) smear `segment.repo` with
274/// non-ownership signals downstream consumers (search, dashboard) may grow to
275/// trust. Standalone callers that want "what does this entry mention?" can use
276/// [`resolve_bucket`] with `BucketingSource::ContentMention`.
277pub fn infer_tiered_identity_from_entry(
278    entry: &TimelineEntry,
279    registry: &ProjectHashRegistry,
280) -> Option<TieredIdentity> {
281    if let Some(tiered) = infer_tiered_identity_from_cwd(entry.cwd.as_deref()) {
282        return Some(tiered);
283    }
284
285    // Last resort: try projectHash registry for Gemini sessions.
286    // The cwd field for Gemini sessions is often the projectHash itself.
287    if let Some(cwd) = entry.cwd.as_deref()
288        && looks_like_weak_source_identifier(cwd)
289    {
290        return registry.resolve(cwd);
291    }
292
293    None
294}
295
296/// Classify a raw CWD string into a source tier without resolving identity.
297pub fn classify_cwd_tier(cwd: Option<&str>) -> SourceTier {
298    let Some(raw) = cwd else {
299        return SourceTier::Opaque;
300    };
301    let trimmed = raw.trim();
302    if trimmed.is_empty() {
303        return SourceTier::Opaque;
304    }
305    if looks_like_weak_source_identifier(trimmed) {
306        return SourceTier::Opaque;
307    }
308    let path = expand_home(trimmed);
309    if discover_git_root(&path).is_some() {
310        return SourceTier::Secondary;
311    }
312    if infer_repo_identity_from_known_layout(&path).is_some() {
313        return SourceTier::Fallback;
314    }
315    SourceTier::Opaque
316}
317
318fn build_segment(
319    repo: Option<RepoIdentity>,
320    source_tier: Option<SourceTier>,
321    agent: &str,
322    session_id: &str,
323    entries: Vec<TimelineEntry>,
324) -> SemanticSegment {
325    let kind = classify_segment_kind(&entries);
326    SemanticSegment {
327        repo,
328        source_tier,
329        kind,
330        agent: agent.to_string(),
331        session_id: session_id.to_string(),
332        entries,
333    }
334}
335
336fn classify_segment_kind(entries: &[TimelineEntry]) -> Kind {
337    if entries.is_empty() {
338        return Kind::Other;
339    }
340
341    let has_conversation = entries
342        .iter()
343        .any(|entry| entry.role == "user" || entry.role == "assistant");
344
345    let report_score = entries
346        .iter()
347        .map(|entry| u16::from(classify_report_signal(entry.message.as_str())))
348        .sum::<u16>();
349    let plan_score = entries
350        .iter()
351        .map(|entry| u16::from(classify_plan_signal(entry.message.as_str())))
352        .sum::<u16>();
353
354    if report_score >= 2 && report_score > plan_score && !has_conversation {
355        Kind::Reports
356    } else if plan_score >= 2 && plan_score >= report_score {
357        Kind::Plans
358    } else if has_conversation {
359        Kind::Conversations
360    } else if report_score > 0 {
361        Kind::Reports
362    } else {
363        Kind::Other
364    }
365}
366
367fn classify_plan_signal(message: &str) -> u8 {
368    let lower = message.to_ascii_lowercase();
369    u8::from(lower.contains("goal:"))
370        + u8::from(lower.contains("acceptance:"))
371        + u8::from(lower.contains("test gate:"))
372        + u8::from(lower.contains("- [ ]"))
373        + u8::from(lower.contains("plan:"))
374        + u8::from(lower.contains("migration plan"))
375}
376
377fn classify_report_signal(message: &str) -> u8 {
378    let lower = message.to_ascii_lowercase();
379    u8::from(lower.contains("recovery report"))
380        + u8::from(lower.contains("audit report"))
381        + u8::from(lower.contains("coverage report"))
382        + u8::from(lower.contains("status report"))
383        + u8::from(lower.contains("summary"))
384}
385
386fn infer_repo_identity_from_path(path: &Path) -> Option<RepoIdentity> {
387    if let Some(repo) = infer_repo_identity_from_local_git(path) {
388        return Some(repo);
389    }
390
391    infer_repo_identity_from_known_layout(path)
392}
393
394// ── Tiered inference helpers ──────────────────────────────────────────────
395
396fn infer_tiered_identity_from_text(text: &str) -> Option<TieredIdentity> {
397    // Content mentions are tag-only signals — never assertable. URL mentions
398    // map to Fallback identity for search hinting. FS-resolved path mentions
399    // were removed: chunks can quote any path on disk, and walking the FS to
400    // validate them leaks ownership from unrelated local repos through
401    // `git remote get-url origin` (see fix/segmentation-identity-leak).
402    infer_repo_identity_from_remote_like(text).map(|identity| TieredIdentity {
403        identity,
404        tier: SourceTier::Fallback,
405    })
406}
407
408/// Resolve a canonical `(organization, repository)` identity from a
409/// cwd string by consulting ground-truth signals — git remote URL,
410/// then known-layout heuristics, finally URL-shape inference.
411///
412/// Returns `None` when no canonical identity can be honestly resolved.
413/// Made `pub` so `src/sources.rs::project_filter_matches_path` can use
414/// the same canonical resolver instead of the prior path-segment
415/// heuristic (which leaked cross-org per `chatgpt-codex-connector`
416/// P1 review on PR #8; see Wave F-2 follow-up commit body).
417pub fn infer_tiered_identity_from_cwd(cwd: Option<&str>) -> Option<TieredIdentity> {
418    let cwd = cwd?.trim();
419    if cwd.is_empty() || looks_like_weak_source_identifier(cwd) {
420        return None;
421    }
422
423    // Remote-like CWD → Primary
424    if let Some(identity) = infer_repo_identity_from_remote_like(cwd) {
425        return Some(TieredIdentity {
426            identity,
427            tier: SourceTier::Primary,
428        });
429    }
430
431    let path = expand_home(cwd);
432    infer_tiered_identity_from_path(&path)
433}
434
435fn infer_tiered_identity_from_path(path: &Path) -> Option<TieredIdentity> {
436    // Local git with remote → Primary
437    if let Some(repo_root) = discover_git_root(path) {
438        if let Some(identity) = infer_repo_identity_from_git_remote(&repo_root) {
439            return Some(TieredIdentity {
440                identity,
441                tier: SourceTier::Primary,
442            });
443        }
444        // Local git with known layout → Secondary
445        if let Some(identity) = infer_repo_identity_from_known_layout(&repo_root) {
446            return Some(TieredIdentity {
447                identity,
448                tier: SourceTier::Secondary,
449            });
450        }
451        // Local git, basename only → Secondary
452        if let Some(name) = repo_root.file_name() {
453            return Some(TieredIdentity {
454                identity: RepoIdentity {
455                    organization: "local".to_string(),
456                    repository: name.to_string_lossy().to_string(),
457                },
458                tier: SourceTier::Secondary,
459            });
460        }
461    }
462
463    // Known layout without .git → Fallback
464    if let Some(identity) = infer_repo_identity_from_known_layout(path) {
465        return Some(TieredIdentity {
466            identity,
467            tier: SourceTier::Fallback,
468        });
469    }
470
471    None
472}
473
474fn infer_repo_identity_from_local_git(path: &Path) -> Option<RepoIdentity> {
475    let repo_root = discover_git_root(path)?;
476    infer_repo_identity_from_git_remote(&repo_root)
477        .or_else(|| infer_repo_identity_from_known_layout(&repo_root))
478        .or_else(|| {
479            repo_root.file_name().map(|name| RepoIdentity {
480                organization: "local".to_string(),
481                repository: name.to_string_lossy().to_string(),
482            })
483        })
484}
485
486fn discover_git_root(path: &Path) -> Option<PathBuf> {
487    let seed = if path.is_file() {
488        path.parent()?.to_path_buf()
489    } else {
490        path.to_path_buf()
491    };
492
493    seed.ancestors()
494        .find(|candidate| candidate.join(".git").exists())
495        .map(Path::to_path_buf)
496}
497
498fn infer_repo_identity_from_git_remote(repo_root: &Path) -> Option<RepoIdentity> {
499    let output = Command::new("git")
500        .arg("-C")
501        .arg(repo_root)
502        .args(["remote", "get-url", "origin"])
503        .output()
504        .ok()?;
505
506    if !output.status.success() {
507        return None;
508    }
509
510    let remote = String::from_utf8_lossy(&output.stdout);
511    infer_repo_identity_from_remote_like(remote.trim())
512}
513
514fn infer_repo_identity_from_known_layout(path: &Path) -> Option<RepoIdentity> {
515    let components: Vec<String> = path
516        .components()
517        .map(|component| component.as_os_str().to_string_lossy().to_string())
518        .collect();
519
520    for marker in ["hosted", "repos", "repositories", "github", "git"] {
521        let Some(marker_index) = components
522            .iter()
523            .position(|component| component.eq_ignore_ascii_case(marker))
524        else {
525            continue;
526        };
527        if components.len() > marker_index + 2 {
528            let organization = components[marker_index + 1].clone();
529            let repository = components[marker_index + 2].clone();
530            if is_probably_repo_name(&organization) && is_probably_repo_name(&repository) {
531                return Some(RepoIdentity {
532                    organization,
533                    repository,
534                });
535            }
536        }
537    }
538
539    None
540}
541
542fn infer_repo_identity_from_remote_like(raw: &str) -> Option<RepoIdentity> {
543    for token in raw.split_whitespace() {
544        let trimmed = token
545            .trim_matches(|ch: char| matches!(ch, '"' | '\'' | ',' | '.' | ')' | '(' | '[' | ']'));
546        for prefix in [
547            "https://github.com/",
548            "http://github.com/",
549            "https://gitlab.com/",
550            "http://gitlab.com/",
551            "git@github.com:",
552            "git@gitlab.com:",
553        ] {
554            if let Some(rest) = trimmed.strip_prefix(prefix)
555                && let Some(repo) = repo_identity_from_remote_path(rest)
556            {
557                return Some(repo);
558            }
559        }
560    }
561
562    None
563}
564
565fn repo_identity_from_remote_path(path: &str) -> Option<RepoIdentity> {
566    let mut parts = path.split('/');
567    let organization = parts.next()?.trim();
568    let repository = parts.next()?.trim().trim_end_matches(".git");
569
570    if is_probably_repo_name(organization) && is_probably_repo_name(repository) {
571        return Some(RepoIdentity {
572            organization: organization.to_string(),
573            repository: repository.to_string(),
574        });
575    }
576
577    Some(RepoIdentity {
578        organization: "local".to_string(),
579        repository: local_repo_fallback(repository),
580    })
581}
582
583fn local_repo_fallback(repository: &str) -> String {
584    if is_probably_repo_name(repository) {
585        repository.to_string()
586    } else {
587        "unknown".to_string()
588    }
589}
590
591fn looks_like_weak_source_identifier(raw: &str) -> bool {
592    let trimmed = raw.trim();
593    trimmed.len() >= 16
594        && trimmed.chars().all(|ch| ch.is_ascii_hexdigit())
595        && !trimmed.contains('/')
596        && !trimmed.contains(':')
597}
598
599fn expand_home(raw: &str) -> PathBuf {
600    if let Some(rest) = raw.strip_prefix("~/")
601        && let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
602    {
603        return home.join(rest);
604    }
605
606    PathBuf::from(raw)
607}
608
609fn is_probably_repo_name(value: &str) -> bool {
610    if value.is_empty() || value.len() > 64 {
611        return false;
612    }
613
614    let mut chars = value.chars();
615    let Some(first) = chars.next() else {
616        return false;
617    };
618    if !first.is_ascii_alphanumeric() {
619        return false;
620    }
621    if !chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_')) {
622        return false;
623    }
624
625    let lower = value.to_ascii_lowercase();
626    if matches!(
627        lower.as_str(),
628        "." | ".."
629            | "..."
630            | "local"
631            | "tmp"
632            | "temp"
633            | "src"
634            | "app"
635            | "lib"
636            | "docs"
637            | "workspace"
638            | "workspaces"
639    ) {
640        return false;
641    }
642
643    let dot_count = value.chars().filter(|ch| *ch == '.').count();
644    if dot_count > value.chars().count() / 2 {
645        return false;
646    }
647
648    // Date-shaped strings (`2026-01-22`, `2026_01_22`, `2026_0122`) sneak past
649    // the alphanumeric+`.-_` filter and have landed in the canonical store as
650    // pseudo-repos before. Treat them as not-a-repo so layout inference and
651    // segmentation never accept a folder dated like a session-bucket.
652    if looks_like_date_pattern(value) {
653        return false;
654    }
655
656    true
657}
658
659/// Returns true if `value` is shaped like a calendar date (no other content).
660///
661/// Recognized: `YYYY-MM-DD`, `YYYY_MM_DD`, `YYYY_MMDD`. The check is
662/// shape-only (digits + separator placement); we do not validate that the
663/// month/day fall within a real calendar — `2026-99-99` is still rejected as
664/// "repo-like" because the *intent* is clearly a date bucket, not a repo.
665/// The `YYYY_MMDD` arm is intentionally aligned with state migration's
666/// compact store date-dir skip heuristic.
667fn looks_like_date_pattern(value: &str) -> bool {
668    let bytes = value.as_bytes();
669    match bytes.len() {
670        // YYYY-MM-DD or YYYY_MM_DD
671        10 => {
672            bytes[..4].iter().all(u8::is_ascii_digit)
673                && matches!(bytes[4], b'-' | b'_')
674                && bytes[5..7].iter().all(u8::is_ascii_digit)
675                && bytes[7] == bytes[4]
676                && bytes[8..10].iter().all(u8::is_ascii_digit)
677        }
678        // YYYY_MMDD (compact form used by the canonical store layout)
679        9 => {
680            bytes[..4].iter().all(u8::is_ascii_digit)
681                && bytes[4] == b'_'
682                && bytes[5..9].iter().all(u8::is_ascii_digit)
683        }
684        _ => false,
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use chrono::{TimeZone, Utc};
692    use std::fs;
693
694    fn entry(
695        ts: (i32, u32, u32, u32, u32, u32),
696        session_id: &str,
697        role: &str,
698        message: &str,
699        cwd: Option<&str>,
700    ) -> TimelineEntry {
701        TimelineEntry {
702            timestamp: Utc
703                .with_ymd_and_hms(ts.0, ts.1, ts.2, ts.3, ts.4, ts.5)
704                .unwrap(),
705            agent: "claude".to_string(),
706            session_id: session_id.to_string(),
707            role: role.to_string(),
708            message: message.to_string(),
709            branch: None,
710            cwd: cwd.map(ToOwned::to_owned),
711            timestamp_source: None,
712            source_path: None,
713            source_sha256: None,
714            source_line_span: None,
715            frame_kind: None,
716        }
717    }
718
719    fn mk_tmp_dir(name: &str) -> PathBuf {
720        std::env::temp_dir().join(format!(
721            "ai-contexters-segmentation-{name}-{}-{}",
722            std::process::id(),
723            Utc::now().timestamp_nanos_opt().unwrap_or_default()
724        ))
725    }
726
727    #[test]
728    fn segment_kind_scoring_handles_large_signal_counts_without_overflow() {
729        let entries: Vec<TimelineEntry> = (0..300)
730            .map(|idx| {
731                entry(
732                    (2026, 3, 21, 9, 0, 0),
733                    "huge-session",
734                    "system",
735                    &format!("status report {idx}\nsummary: follow-up required"),
736                    None,
737                )
738            })
739            .collect();
740
741        assert_eq!(classify_segment_kind(&entries), Kind::Reports);
742    }
743
744    #[test]
745    fn repo_signal_segmentation_splits_one_session_across_multiple_repositories() {
746        // Multi-repo split must come from a source-of-truth signal (cwd
747        // switch between two real git repos), NOT from text URL mentions
748        // in chunks. Pre-round-2 a session that merely *mentioned* repo B
749        // mid-conversation would split into a second non-assertable
750        // segment with B's URL as identity; that smear is gone.
751        let root = mk_tmp_dir("multi-repo-cwd-switch");
752        let repo_a = root.join("hosted").join("Vetcoders").join("ai-contexters");
753        let repo_b = root.join("hosted").join("Vetcoders").join("loctree");
754        for r in [&repo_a, &repo_b] {
755            fs::create_dir_all(r).unwrap();
756            Command::new("git").arg("init").arg(r).output().unwrap();
757        }
758
759        let cwd_a = repo_a.to_string_lossy().to_string();
760        let cwd_b = repo_b.to_string_lossy().to_string();
761        let entries = vec![
762            entry(
763                (2026, 3, 21, 9, 0, 0),
764                "sess-1",
765                "user",
766                "Please inspect ai-contexters before editing.",
767                Some(&cwd_a),
768            ),
769            entry(
770                (2026, 3, 21, 9, 1, 0),
771                "sess-1",
772                "assistant",
773                "I found the store seam.",
774                Some(&cwd_a),
775            ),
776            entry(
777                (2026, 3, 21, 9, 2, 0),
778                "sess-1",
779                "user",
780                "Switch to loctree now.",
781                Some(&cwd_b),
782            ),
783            entry(
784                (2026, 3, 21, 9, 3, 0),
785                "sess-1",
786                "assistant",
787                "Reviewing loctree next.",
788                Some(&cwd_b),
789            ),
790        ];
791
792        let segments = semantic_segments(&entries);
793        assert_eq!(segments.len(), 2);
794        assert_eq!(segments[0].project_label(), "Vetcoders/ai-contexters");
795        assert_eq!(segments[1].project_label(), "Vetcoders/loctree");
796
797        let _ = fs::remove_dir_all(&root);
798    }
799
800    #[test]
801    fn repo_signal_segmentation_keeps_unknown_prefix_honest() {
802        // First half of the session has no cwd (no identity); the user then
803        // starts working in a real on-disk repo. Segmentation must split:
804        // a no-identity prefix (preserved as plan/setup talk) followed by
805        // a properly-owned segment once cwd lands. Previously this was
806        // exercised by a text URL mention; that path is no longer a signal.
807        let root = mk_tmp_dir("unknown-prefix-then-cwd");
808        let repo = root.join("hosted").join("Vetcoders").join("ai-contexters");
809        fs::create_dir_all(&repo).unwrap();
810        Command::new("git").arg("init").arg(&repo).output().unwrap();
811
812        let cwd = repo.to_string_lossy().to_string();
813        let entries = vec![
814            entry(
815                (2026, 3, 21, 9, 0, 0),
816                "sess-2",
817                "user",
818                "Need a migration plan but I have not named the repo yet.",
819                None,
820            ),
821            entry(
822                (2026, 3, 21, 9, 1, 0),
823                "sess-2",
824                "assistant",
825                "Drafting a migration plan with acceptance criteria.",
826                None,
827            ),
828            entry(
829                (2026, 3, 21, 9, 2, 0),
830                "sess-2",
831                "user",
832                "Now working in the repo on disk.",
833                Some(&cwd),
834            ),
835        ];
836
837        let segments = semantic_segments(&entries);
838        assert_eq!(segments.len(), 2);
839        assert!(segments[0].repo.is_none());
840        assert_eq!(segments[0].kind, Kind::Plans);
841        assert_eq!(segments[1].project_label(), "Vetcoders/ai-contexters");
842
843        let _ = fs::remove_dir_all(&root);
844    }
845
846    #[test]
847    fn repo_signal_segmentation_ignores_gemini_hash_like_cwd() {
848        let entry = entry(
849            (2026, 3, 21, 9, 0, 0),
850            "sess-3",
851            "user",
852            "No trustworthy repo here.",
853            Some("57cfd37b3a72d995c4f2d018ebf9d5a2"),
854        );
855
856        assert!(infer_repo_identity_from_entry(&entry).is_none());
857        let segments = semantic_segments(&[entry]);
858        assert_eq!(segments.len(), 1);
859        assert!(segments[0].repo.is_none());
860    }
861
862    #[test]
863    fn repo_signal_segmentation_uses_local_git_remote_when_available() {
864        let root = mk_tmp_dir("git-remote");
865        let repo = root.join("hosted").join("Vetcoders").join("ai-contexters");
866        fs::create_dir_all(&repo).unwrap();
867
868        Command::new("git")
869            .arg("init")
870            .arg(&repo)
871            .output()
872            .expect("git init should run");
873        Command::new("git")
874            .arg("-C")
875            .arg(&repo)
876            .args([
877                "remote",
878                "add",
879                "origin",
880                "git@github.com:Vetcoders/ai-contexters.git",
881            ])
882            .output()
883            .expect("git remote add should run");
884
885        let entry = entry(
886            (2026, 3, 21, 9, 0, 0),
887            "sess-4",
888            "user",
889            "Inspect the repo on disk.",
890            Some(repo.to_string_lossy().as_ref()),
891        );
892
893        let repo_identity = infer_repo_identity_from_entry(&entry).expect("repo identity");
894        assert_eq!(repo_identity.slug(), "Vetcoders/ai-contexters");
895
896        let _ = fs::remove_dir_all(&root);
897    }
898
899    // ================================================================
900    // Source tier tests
901    // ================================================================
902
903    // (Removed `source_tier_github_url_is_primary` — superseded by
904    //  `entry_identity_ignores_text_url_mentions` /
905    //  `resolve_bucket_still_surfaces_text_url_mentions` below.)
906
907    #[test]
908    fn cwd_git_identity_wins_over_content_mentions() {
909        let root = mk_tmp_dir("cwd-wins");
910        let repo = root.join("Git").join("vista");
911        fs::create_dir_all(&repo).unwrap();
912
913        Command::new("git")
914            .arg("init")
915            .arg(&repo)
916            .output()
917            .expect("git init");
918
919        let e = entry(
920            (2026, 5, 6, 10, 0, 0),
921            "sess-cwd-wins",
922            "user",
923            "We need to inspect https://github.com/RustCrypto/RSA while working locally.",
924            Some(repo.to_string_lossy().as_ref()),
925        );
926
927        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
928        assert_eq!(resolution.bucket, "local/vista");
929        assert_eq!(resolution.source, BucketingSource::CwdGitRoot);
930
931        let _ = fs::remove_dir_all(&root);
932    }
933
934    #[test]
935    fn rejects_template_literals() {
936        assert!(!is_probably_repo_name("{target_owner}"));
937        assert!(!is_probably_repo_name("<YOUR_USERNAME>"));
938        assert!(!is_probably_repo_name("${RELEASE_REPO}"));
939        assert!(!is_probably_repo_name("$REPO"));
940        assert!(!is_probably_repo_name("{org}"));
941    }
942
943    #[test]
944    fn rejects_dot_only_and_traversal_strings() {
945        assert!(!is_probably_repo_name("..."));
946        assert!(!is_probably_repo_name(".."));
947        assert!(!is_probably_repo_name("."));
948        assert!(!is_probably_repo_name(".../"));
949        assert!(!is_probably_repo_name("..hidden"));
950    }
951
952    #[test]
953    fn rejects_control_chars_and_separators() {
954        assert!(!is_probably_repo_name("foo/bar"));
955        assert!(!is_probably_repo_name("foo\\bar"));
956        assert!(!is_probably_repo_name("foo\nbar"));
957        assert!(!is_probably_repo_name("foo bar"));
958        assert!(!is_probably_repo_name(""));
959    }
960
961    #[test]
962    fn accepts_real_repo_names() {
963        assert!(is_probably_repo_name("vibecrafted"));
964        assert!(is_probably_repo_name("rust-memex"));
965        assert!(is_probably_repo_name("ai-contexters"));
966        assert!(is_probably_repo_name("vc-runtime"));
967        assert!(is_probably_repo_name("Codescribe"));
968        assert!(is_probably_repo_name("starship"));
969        assert!(is_probably_repo_name("01mf02"));
970        assert!(is_probably_repo_name("a"));
971    }
972
973    #[test]
974    fn fallback_routes_invalid_remote_owner_to_local_bucket() {
975        // After round-2, text URLs no longer feed segment identity, so this
976        // test exercises the lower-level `resolve_bucket` API instead. The
977        // malformed-owner → `local/<repo>` rule still belongs to the parser
978        // (anything routing through `infer_repo_identity_from_remote_like`
979        // must not silently materialize a `{placeholder}/...` org).
980        let e = entry(
981            (2026, 3, 22, 10, 0, 0),
982            "sess-local-fallback",
983            "user",
984            "Clone https://github.com/{target_owner}/vibecrafted.git before release.",
985            None,
986        );
987        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
988        assert_eq!(resolution.source, BucketingSource::ContentMention);
989        assert_eq!(resolution.bucket, "local/vibecrafted");
990        assert_ne!(resolution.bucket, "{target_owner}/vibecrafted");
991
992        // Segment pipeline ignores text mentions entirely → non-assertable.
993        let segments = semantic_segments(&[e]);
994        assert_eq!(segments.len(), 1);
995        assert!(segments[0].repo.is_none());
996        assert!(!segments[0].has_assertable_identity());
997    }
998
999    #[test]
1000    fn fallback_routes_invalid_remote_repo_to_unknown_local_bucket() {
1001        let identity = infer_repo_identity_from_remote_like(
1002            "https://github.com/Vetcoders/${RELEASE_REPO}.git",
1003        )
1004        .expect("malformed repository should resolve to local unknown fallback");
1005
1006        assert_eq!(identity.slug(), "local/unknown");
1007    }
1008
1009    #[test]
1010    fn source_tier_git_remote_cwd_is_primary() {
1011        let root = mk_tmp_dir("tier-git-remote");
1012        let repo = root.join("hosted").join("Vetcoders").join("loctree");
1013        fs::create_dir_all(&repo).unwrap();
1014
1015        Command::new("git")
1016            .arg("init")
1017            .arg(&repo)
1018            .output()
1019            .expect("git init");
1020        Command::new("git")
1021            .arg("-C")
1022            .arg(&repo)
1023            .args([
1024                "remote",
1025                "add",
1026                "origin",
1027                "git@github.com:Vetcoders/loctree.git",
1028            ])
1029            .output()
1030            .expect("git remote add");
1031
1032        let e = entry(
1033            (2026, 3, 22, 10, 0, 0),
1034            "sess-tier-git",
1035            "user",
1036            "Working in the repo.",
1037            Some(repo.to_string_lossy().as_ref()),
1038        );
1039
1040        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default())
1041            .expect("should resolve");
1042        assert_eq!(tiered.tier, SourceTier::Primary);
1043        assert_eq!(tiered.identity.slug(), "Vetcoders/loctree");
1044
1045        let _ = fs::remove_dir_all(&root);
1046    }
1047
1048    #[test]
1049    fn source_tier_known_layout_without_git_is_fallback() {
1050        let e = entry(
1051            (2026, 3, 22, 10, 0, 0),
1052            "sess-tier-layout",
1053            "user",
1054            "Working at /nonexistent/hosted/SomeOrg/SomeRepo",
1055            None,
1056        );
1057        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default());
1058        // Path in message text resolved via known layout (no .git) → Fallback
1059        if let Some(t) = tiered {
1060            assert_eq!(t.tier, SourceTier::Fallback);
1061            assert!(!t.tier.is_assertable());
1062        }
1063        // It's also OK if it returns None (path doesn't exist on disk)
1064    }
1065
1066    #[test]
1067    fn source_tier_hex_hash_cwd_is_opaque_without_registry() {
1068        let e = entry(
1069            (2026, 3, 22, 10, 0, 0),
1070            "sess-tier-hash",
1071            "user",
1072            "Hello from Gemini.",
1073            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1074        );
1075        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default());
1076        assert!(
1077            tiered.is_none(),
1078            "hex hash without registry must not resolve"
1079        );
1080    }
1081
1082    #[test]
1083    fn source_tier_hex_hash_resolves_through_registry() {
1084        let root = mk_tmp_dir("tier-registry");
1085        let repo = root.join("hosted").join("Vetcoders").join("ai-contexters");
1086        fs::create_dir_all(&repo).unwrap();
1087
1088        Command::new("git")
1089            .arg("init")
1090            .arg(&repo)
1091            .output()
1092            .expect("git init");
1093        Command::new("git")
1094            .arg("-C")
1095            .arg(&repo)
1096            .args([
1097                "remote",
1098                "add",
1099                "origin",
1100                "git@github.com:Vetcoders/ai-contexters.git",
1101            ])
1102            .output()
1103            .expect("git remote add");
1104
1105        let mut registry = ProjectHashRegistry::default();
1106        registry.mappings.insert(
1107            "fef6ad02174d592d21e7f8a6143564388027ec0c".to_string(),
1108            repo.to_string_lossy().to_string(),
1109        );
1110
1111        let e = entry(
1112            (2026, 3, 22, 10, 0, 0),
1113            "sess-tier-reg",
1114            "user",
1115            "Hello from Gemini.",
1116            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1117        );
1118
1119        let tiered =
1120            infer_tiered_identity_from_entry(&e, &registry).expect("registry should resolve");
1121        assert_eq!(tiered.tier, SourceTier::Secondary);
1122        assert_eq!(tiered.identity.slug(), "Vetcoders/ai-contexters");
1123        assert!(tiered.tier.is_assertable());
1124
1125        let _ = fs::remove_dir_all(&root);
1126    }
1127
1128    #[test]
1129    fn source_tier_registry_with_unknown_hash_returns_none() {
1130        let registry = ProjectHashRegistry::default();
1131        let e = entry(
1132            (2026, 3, 22, 10, 0, 0),
1133            "sess-tier-unknown",
1134            "user",
1135            "Hello from Gemini.",
1136            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
1137        );
1138        let tiered = infer_tiered_identity_from_entry(&e, &registry);
1139        assert!(
1140            tiered.is_none(),
1141            "unknown hash must not resolve even with empty registry"
1142        );
1143    }
1144
1145    #[test]
1146    fn source_tier_classify_cwd_empty_is_opaque() {
1147        assert_eq!(classify_cwd_tier(None), SourceTier::Opaque);
1148        assert_eq!(classify_cwd_tier(Some("")), SourceTier::Opaque);
1149    }
1150
1151    #[test]
1152    fn source_tier_classify_cwd_hex_is_opaque() {
1153        assert_eq!(
1154            classify_cwd_tier(Some("57cfd37b3a72d995c4f2d018ebf9d5a2")),
1155            SourceTier::Opaque
1156        );
1157    }
1158
1159    #[test]
1160    fn segments_carry_source_tier() {
1161        // Source-tier propagation is now demonstrated through cwd-derived
1162        // identity (the only entry-level identity path). Text URL mentions
1163        // no longer produce a Fallback-tier segment.
1164        let root = mk_tmp_dir("segments-carry-tier");
1165        let repo = root.join("hosted").join("Vetcoders").join("ai-contexters");
1166        fs::create_dir_all(&repo).unwrap();
1167        Command::new("git").arg("init").arg(&repo).output().unwrap();
1168
1169        let cwd = repo.to_string_lossy().to_string();
1170        let entries = vec![
1171            entry(
1172                (2026, 3, 22, 10, 0, 0),
1173                "sess-st",
1174                "user",
1175                "Working on ai-contexters.",
1176                Some(&cwd),
1177            ),
1178            entry(
1179                (2026, 3, 22, 10, 1, 0),
1180                "sess-st",
1181                "assistant",
1182                "Reviewing now.",
1183                Some(&cwd),
1184            ),
1185        ];
1186
1187        let segments = semantic_segments(&entries);
1188        assert_eq!(segments.len(), 1);
1189        assert_eq!(segments[0].source_tier, Some(SourceTier::Secondary));
1190        assert!(segments[0].has_assertable_identity());
1191
1192        let _ = fs::remove_dir_all(&root);
1193    }
1194
1195    #[test]
1196    fn segments_without_repo_have_no_tier() {
1197        let entries = vec![entry(
1198            (2026, 3, 22, 10, 0, 0),
1199            "sess-none",
1200            "user",
1201            "Just chatting, no repo context.",
1202            None,
1203        )];
1204
1205        let segments = semantic_segments(&entries);
1206        assert_eq!(segments.len(), 1);
1207        assert!(segments[0].repo.is_none());
1208        assert!(segments[0].source_tier.is_none());
1209        assert!(!segments[0].has_assertable_identity());
1210    }
1211
1212    #[test]
1213    fn segments_opaque_cwd_routes_to_non_repo() {
1214        let entries = vec![entry(
1215            (2026, 3, 22, 10, 0, 0),
1216            "sess-opaque",
1217            "user",
1218            "Gemini session with opaque hash only.",
1219            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1220        )];
1221
1222        let segments = semantic_segments(&entries);
1223        assert_eq!(segments.len(), 1);
1224        assert!(segments[0].repo.is_none());
1225        assert_eq!(segments[0].project_label(), "non-repository-contexts");
1226    }
1227
1228    #[test]
1229    fn segments_opaque_cwd_resolves_with_registry() {
1230        let root = mk_tmp_dir("seg-registry");
1231        let repo = root.join("hosted").join("Vetcoders").join("ai-contexters");
1232        fs::create_dir_all(&repo).unwrap();
1233
1234        Command::new("git")
1235            .arg("init")
1236            .arg(&repo)
1237            .output()
1238            .expect("git init");
1239        Command::new("git")
1240            .arg("-C")
1241            .arg(&repo)
1242            .args([
1243                "remote",
1244                "add",
1245                "origin",
1246                "git@github.com:Vetcoders/ai-contexters.git",
1247            ])
1248            .output()
1249            .expect("git remote add");
1250
1251        let mut registry = ProjectHashRegistry::default();
1252        registry.mappings.insert(
1253            "fef6ad02174d592d21e7f8a6143564388027ec0c".to_string(),
1254            repo.to_string_lossy().to_string(),
1255        );
1256
1257        let entries = vec![entry(
1258            (2026, 3, 22, 10, 0, 0),
1259            "sess-reg",
1260            "user",
1261            "Gemini session with mapped hash.",
1262            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1263        )];
1264
1265        let segments = semantic_segments_with_registry(&entries, &registry);
1266        assert_eq!(segments.len(), 1);
1267        assert!(segments[0].repo.is_some());
1268        assert_eq!(segments[0].source_tier, Some(SourceTier::Secondary));
1269        assert_eq!(segments[0].project_label(), "Vetcoders/ai-contexters");
1270
1271        let _ = fs::remove_dir_all(&root);
1272    }
1273
1274    #[test]
1275    fn project_hash_registry_roundtrip() {
1276        let root = mk_tmp_dir("registry-roundtrip");
1277        fs::create_dir_all(&root).unwrap();
1278        let path = root.join("gemini-project-map.json");
1279
1280        let mut registry = ProjectHashRegistry::default();
1281        registry.mappings.insert(
1282            "abc123".to_string(),
1283            "/home/user/repos/my-project".to_string(),
1284        );
1285
1286        let json = serde_json::to_string_pretty(&registry).unwrap();
1287        fs::write(&path, &json).unwrap();
1288
1289        let loaded = ProjectHashRegistry::load_from(&path);
1290        assert_eq!(loaded.mappings.len(), 1);
1291        assert_eq!(
1292            loaded.mappings.get("abc123").map(String::as_str),
1293            Some("/home/user/repos/my-project")
1294        );
1295
1296        let _ = fs::remove_dir_all(&root);
1297    }
1298
1299    #[test]
1300    fn project_hash_registry_missing_file_returns_empty() {
1301        let registry = ProjectHashRegistry::load_from(Path::new("/nonexistent/path.json"));
1302        assert!(registry.mappings.is_empty());
1303    }
1304
1305    #[test]
1306    fn source_tier_ordering() {
1307        assert!(SourceTier::Primary < SourceTier::Secondary);
1308        assert!(SourceTier::Secondary < SourceTier::Fallback);
1309        assert!(SourceTier::Fallback < SourceTier::Opaque);
1310    }
1311
1312    #[test]
1313    fn source_tier_assertable_boundaries() {
1314        assert!(SourceTier::Primary.is_assertable());
1315        assert!(SourceTier::Secondary.is_assertable());
1316        assert!(!SourceTier::Fallback.is_assertable());
1317        assert!(!SourceTier::Opaque.is_assertable());
1318    }
1319
1320    #[test]
1321    fn infer_repo_identity_from_known_layout_matches_hosted() {
1322        let path = Path::new("/Users/x/hosted/MyOrg/my-repo/src/lib.rs");
1323        let id = infer_repo_identity_from_known_layout(path).expect("hosted match");
1324        assert_eq!(id.organization, "MyOrg");
1325        assert_eq!(id.repository, "my-repo");
1326    }
1327
1328    #[test]
1329    fn infer_repo_identity_from_known_layout_matches_repos() {
1330        // Previously dead code: `?` inside the loop returned from the function
1331        // on the first missing marker, so only "hosted" was ever tried.
1332        let path = Path::new("/Users/x/repos/MyOrg/my-repo/src/lib.rs");
1333        let id = infer_repo_identity_from_known_layout(path).expect("repos match");
1334        assert_eq!(id.organization, "MyOrg");
1335        assert_eq!(id.repository, "my-repo");
1336    }
1337
1338    #[test]
1339    fn infer_repo_identity_from_known_layout_matches_repositories() {
1340        let path = Path::new("/Users/x/repositories/MyOrg/my-repo/file.txt");
1341        let id = infer_repo_identity_from_known_layout(path).expect("repositories match");
1342        assert_eq!(id.organization, "MyOrg");
1343        assert_eq!(id.repository, "my-repo");
1344    }
1345
1346    #[test]
1347    fn infer_repo_identity_from_known_layout_matches_github() {
1348        let path = Path::new("/home/user/github/MyOrg/my-repo/src");
1349        let id = infer_repo_identity_from_known_layout(path).expect("github match");
1350        assert_eq!(id.organization, "MyOrg");
1351        assert_eq!(id.repository, "my-repo");
1352    }
1353
1354    #[test]
1355    fn infer_repo_identity_from_known_layout_matches_git() {
1356        let path = Path::new("/home/user/git/MyOrg/my-repo");
1357        let id = infer_repo_identity_from_known_layout(path).expect("git match");
1358        assert_eq!(id.organization, "MyOrg");
1359        assert_eq!(id.repository, "my-repo");
1360    }
1361
1362    #[test]
1363    fn infer_repo_identity_from_known_layout_returns_none_when_no_marker() {
1364        let path = Path::new("/tmp/scratch/work/file.txt");
1365        assert!(infer_repo_identity_from_known_layout(path).is_none());
1366    }
1367
1368    #[test]
1369    fn infer_repo_identity_from_known_layout_rejects_non_repo_name_segments() {
1370        // The `is_probably_repo_name` guard still applies after the loop fix.
1371        // Org/repo segments must be alphanumeric + `.-_` only.
1372        let path = Path::new("/Users/x/repos/My Org With Spaces/my-repo");
1373        assert!(infer_repo_identity_from_known_layout(path).is_none());
1374    }
1375
1376    // ================================================================
1377    // Identity-leak regression: text mentions must not assert ownership
1378    // ================================================================
1379
1380    #[test]
1381    fn known_layout_marker_matches_case_insensitive() {
1382        // Mac convention `/Users/<u>/Git/...` (capital G) must match the
1383        // lowercase `git` marker. Previously case-sensitive comparison rejected
1384        // it, sending identity inference into the now-removed text fallback.
1385        let path = Path::new("/Users/user/Git/Vetcoders/ai-contexters/src/lib.rs");
1386        let id = infer_repo_identity_from_known_layout(path).expect("Git (capital) matches");
1387        assert_eq!(id.organization, "Vetcoders");
1388        assert_eq!(id.repository, "ai-contexters");
1389
1390        // Mixed-case markers also accepted (defensive).
1391        let mixed = Path::new("/srv/Hosted/OrgA/repo-x");
1392        let id_mixed = infer_repo_identity_from_known_layout(mixed).expect("Hosted match");
1393        assert_eq!(id_mixed.organization, "OrgA");
1394        assert_eq!(id_mixed.repository, "repo-x");
1395    }
1396
1397    #[test]
1398    fn text_mention_with_disk_path_no_longer_resolves_to_assertable_tier() {
1399        // Regression: previously, a text mention containing any absolute path
1400        // that walked to a real `.git` on disk produced a Primary/Secondary
1401        // tier via `git remote get-url origin`. That leaked ownership from
1402        // local-clone-folder-name → remote-URL repo (e.g. cwd `vista-codex`
1403        // with chunk mentioning `/Users/.../ai-collaborators/.git/...` and that
1404        // repo's remote pointing to `Szowesgad/maciej-almanach.git`).
1405        //
1406        // After fix: `infer_tiered_identity_from_text` only reads `https://github.com/X/Y`
1407        // URL mentions and clamps the tier to Fallback. Path mentions are ignored.
1408        let root = mk_tmp_dir("text-path-no-leak");
1409        let real_git_repo = root.join("hosted").join("EvilOrg").join("evil-repo");
1410        fs::create_dir_all(&real_git_repo).unwrap();
1411        Command::new("git")
1412            .arg("init")
1413            .arg(&real_git_repo)
1414            .output()
1415            .expect("git init");
1416        Command::new("git")
1417            .arg("-C")
1418            .arg(&real_git_repo)
1419            .args([
1420                "remote",
1421                "add",
1422                "origin",
1423                "git@github.com:EvilOrg/evil-repo.git",
1424            ])
1425            .output()
1426            .expect("git remote add");
1427
1428        let chunk_text = format!(
1429            "Scanning empty directories. Found: {} (this is just a mention)",
1430            real_git_repo.display()
1431        );
1432        let e = entry(
1433            (2026, 5, 19, 10, 0, 0),
1434            "sess-leak",
1435            "user",
1436            &chunk_text,
1437            None,
1438        );
1439
1440        // No identity at all — text path mentions are no longer FS-validated.
1441        assert!(infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default()).is_none());
1442
1443        let _ = fs::remove_dir_all(&root);
1444    }
1445
1446    #[test]
1447    fn entry_identity_ignores_text_url_mentions() {
1448        // Round 2: even URL mentions are no longer entry identity. Promoting
1449        // them produced segment.repo smear and split sessions on context_switch
1450        // when current cwd-derived identity differed from a chunk's mention.
1451        let e = entry(
1452            (2026, 5, 19, 10, 0, 0),
1453            "sess-url",
1454            "user",
1455            "See https://github.com/Vetcoders/aicx for context.",
1456            None,
1457        );
1458        assert!(
1459            infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default()).is_none(),
1460            "entry-level identity must come from cwd/registry only"
1461        );
1462    }
1463
1464    #[test]
1465    fn resolve_bucket_still_surfaces_text_url_mentions() {
1466        // resolve_bucket is a standalone bucket-resolver (separate from segment
1467        // pipeline). Text URL mentions remain a ContentMention signal so future
1468        // search-hint use cases can read them — but they do NOT feed segment
1469        // identity. This test guards that contract.
1470        let e = entry(
1471            (2026, 5, 19, 10, 0, 0),
1472            "sess-bucket",
1473            "user",
1474            "Read https://github.com/Vetcoders/aicx and tell me what you see.",
1475            None,
1476        );
1477        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
1478        assert_eq!(resolution.source, BucketingSource::ContentMention);
1479        assert_eq!(resolution.bucket, "Vetcoders/aicx");
1480    }
1481
1482    #[test]
1483    fn semantic_segment_does_not_split_on_text_url_mention() {
1484        // Round-2 regression: a session whose cwd points at Vetcoders/Vista
1485        // (Primary identity, real .git on disk) and whose middle chunk simply
1486        // mentions a different repo URL must stay as ONE segment with the
1487        // owner-correct identity. Pre-fix this split into two segments
1488        // (vetcoders/Vista → Vetcoders/aicx Fallback) and the second chunk
1489        // routed away from its real owner.
1490        let root = mk_tmp_dir("segment-no-split-on-url");
1491        let repo = root.join("Git").join("Vetcoders").join("vista");
1492        fs::create_dir_all(&repo).unwrap();
1493        Command::new("git").arg("init").arg(&repo).output().unwrap();
1494        Command::new("git")
1495            .arg("-C")
1496            .arg(&repo)
1497            .args([
1498                "remote",
1499                "add",
1500                "origin",
1501                "git@github.com:Vetcoders/vista.git",
1502            ])
1503            .output()
1504            .unwrap();
1505
1506        let cwd = repo.to_string_lossy().to_string();
1507        let entries = vec![
1508            entry(
1509                (2026, 5, 19, 10, 0, 0),
1510                "sess-no-split",
1511                "user",
1512                "Start working on Vista.",
1513                Some(&cwd),
1514            ),
1515            entry(
1516                (2026, 5, 19, 10, 1, 0),
1517                "sess-no-split",
1518                "assistant",
1519                "See https://github.com/Vetcoders/aicx for the shared parser.",
1520                None,
1521            ),
1522            entry(
1523                (2026, 5, 19, 10, 2, 0),
1524                "sess-no-split",
1525                "user",
1526                "Back to Vista.",
1527                Some(&cwd),
1528            ),
1529        ];
1530
1531        let segments = semantic_segments(&entries);
1532        assert_eq!(
1533            segments.len(),
1534            1,
1535            "single-owner session must not split on a text URL mention"
1536        );
1537        assert_eq!(segments[0].project_label(), "Vetcoders/vista");
1538        assert!(segments[0].has_assertable_identity());
1539
1540        let _ = fs::remove_dir_all(&root);
1541    }
1542
1543    #[test]
1544    fn is_probably_repo_name_rejects_date_patterns() {
1545        // Live bug: pseudo-projects like `Codescribe/2026-01-22` made it into
1546        // ~/.aicx/store/. Block all three date shapes used by known layouts.
1547        assert!(!is_probably_repo_name("2026-01-22"));
1548        assert!(!is_probably_repo_name("2026_01_22"));
1549        assert!(!is_probably_repo_name("2026_0122"));
1550        // Sanity: real-shaped repo names with a date suffix still pass.
1551        assert!(is_probably_repo_name("release-2026"));
1552        assert!(is_probably_repo_name("v2026.01"));
1553        assert!(is_probably_repo_name("aicx"));
1554        assert!(is_probably_repo_name("ai-contexters"));
1555    }
1556
1557    #[test]
1558    fn looks_like_date_pattern_recognizes_three_shapes() {
1559        assert!(looks_like_date_pattern("2026-01-22"));
1560        assert!(looks_like_date_pattern("2026_01_22"));
1561        assert!(looks_like_date_pattern("2026_0122"));
1562        // Out-of-range digits are still date-shaped — intent is what matters.
1563        assert!(looks_like_date_pattern("9999-99-99"));
1564        // Negative cases: wrong length, wrong separators, mixed separators.
1565        assert!(!looks_like_date_pattern("2026-0122")); // 9 chars but wrong sep at idx 4
1566        assert!(!looks_like_date_pattern("2026-01_22")); // mixed - and _
1567        assert!(!looks_like_date_pattern("202601-22")); // missing sep at idx 4
1568        assert!(!looks_like_date_pattern("v2026-01-22")); // extra prefix
1569        assert!(!looks_like_date_pattern("aicx"));
1570    }
1571
1572    #[test]
1573    fn semantic_segment_text_only_path_stays_non_assertable() {
1574        // End-to-end: a session with no cwd but with chunks mentioning real
1575        // on-disk paths must NOT produce an assertable segment. Such segments
1576        // route to non-repository-contexts in store, not the canonical bucket.
1577        let root = mk_tmp_dir("segment-text-only");
1578        let real_git_repo = root.join("Git").join("OrgX").join("repo-y");
1579        fs::create_dir_all(&real_git_repo).unwrap();
1580        Command::new("git")
1581            .arg("init")
1582            .arg(&real_git_repo)
1583            .output()
1584            .expect("git init");
1585
1586        let chunk_text = format!("Inspecting {}", real_git_repo.display());
1587        let entries = vec![
1588            entry(
1589                (2026, 5, 19, 10, 0, 0),
1590                "sess-e2e",
1591                "user",
1592                &chunk_text,
1593                None,
1594            ),
1595            entry(
1596                (2026, 5, 19, 10, 1, 0),
1597                "sess-e2e",
1598                "assistant",
1599                "Found some files.",
1600                None,
1601            ),
1602        ];
1603
1604        let segments = semantic_segments(&entries);
1605        assert_eq!(segments.len(), 1);
1606        assert!(
1607            !segments[0].has_assertable_identity(),
1608            "text-only path mention must never produce assertable identity"
1609        );
1610
1611        let _ = fs::remove_dir_all(&root);
1612    }
1613
1614    #[test]
1615    fn project_hash_registry_load_default_honors_aicx_home_then_falls_back_to_home() {
1616        // The registry path was hard-coded to `$HOME/.aicx/...` and
1617        // ignored the operator's `AICX_HOME` override. Confirm the
1618        // helper now honors AICX_HOME first, falls back to HOME/.aicx,
1619        // and returns an empty registry (not panic) when neither env
1620        // var resolves to an existing file.
1621        let aicx_home = mk_tmp_dir("registry-aicx-home");
1622        fs::create_dir_all(&aicx_home).unwrap();
1623        let map_path = aicx_home.join("gemini-project-map.json");
1624        fs::write(
1625            &map_path,
1626            r#"{ "mappings": { "abc123": "/tmp/aicx-test-registry-target" } }"#,
1627        )
1628        .unwrap();
1629
1630        // Serialize env mutation so we don't fight other tests that
1631        // also touch HOME/AICX_HOME — the segmentation module owns no
1632        // other env-touching tests today, but this guard is cheap.
1633        let prev_aicx_home = std::env::var_os("AICX_HOME");
1634        let prev_home = std::env::var_os("HOME");
1635        // SAFETY: This is a single-threaded test and we restore the
1636        // previous values before returning.
1637        unsafe {
1638            std::env::set_var("AICX_HOME", &aicx_home);
1639        }
1640
1641        let loaded = ProjectHashRegistry::load_default();
1642        assert_eq!(
1643            loaded.mappings.get("abc123").map(String::as_str),
1644            Some("/tmp/aicx-test-registry-target"),
1645            "AICX_HOME-rooted registry must load via load_default"
1646        );
1647
1648        // Falling back to HOME/.aicx when AICX_HOME is unset.
1649        let home_root = mk_tmp_dir("registry-home-fallback");
1650        let home_aicx = home_root.join(".aicx");
1651        fs::create_dir_all(&home_aicx).unwrap();
1652        fs::write(
1653            home_aicx.join("gemini-project-map.json"),
1654            r#"{ "mappings": { "def456": "/tmp/aicx-test-registry-home" } }"#,
1655        )
1656        .unwrap();
1657        unsafe {
1658            std::env::remove_var("AICX_HOME");
1659            std::env::set_var("HOME", &home_root);
1660        }
1661        let loaded_home = ProjectHashRegistry::load_default();
1662        assert_eq!(
1663            loaded_home.mappings.get("def456").map(String::as_str),
1664            Some("/tmp/aicx-test-registry-home"),
1665            "HOME/.aicx must remain the fallback when AICX_HOME is unset"
1666        );
1667
1668        // Restore env to whatever the runner had before.
1669        unsafe {
1670            match prev_aicx_home {
1671                Some(v) => std::env::set_var("AICX_HOME", v),
1672                None => std::env::remove_var("AICX_HOME"),
1673            }
1674            match prev_home {
1675                Some(v) => std::env::set_var("HOME", v),
1676                None => std::env::remove_var("HOME"),
1677            }
1678        }
1679        let _ = fs::remove_dir_all(&aicx_home);
1680        let _ = fs::remove_dir_all(&home_root);
1681    }
1682}