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| classify_report_signal(entry.message.as_str()))
348        .sum::<u8>();
349    let plan_score = entries
350        .iter()
351        .map(|entry| classify_plan_signal(entry.message.as_str()))
352        .sum::<u8>();
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            frame_kind: None,
713        }
714    }
715
716    fn mk_tmp_dir(name: &str) -> PathBuf {
717        std::env::temp_dir().join(format!(
718            "ai-contexters-segmentation-{name}-{}-{}",
719            std::process::id(),
720            Utc::now().timestamp_nanos_opt().unwrap_or_default()
721        ))
722    }
723
724    #[test]
725    fn repo_signal_segmentation_splits_one_session_across_multiple_repositories() {
726        // Multi-repo split must come from a source-of-truth signal (cwd
727        // switch between two real git repos), NOT from text URL mentions
728        // in chunks. Pre-round-2 a session that merely *mentioned* repo B
729        // mid-conversation would split into a second non-assertable
730        // segment with B's URL as identity; that smear is gone.
731        let root = mk_tmp_dir("multi-repo-cwd-switch");
732        let repo_a = root.join("hosted").join("VetCoders").join("ai-contexters");
733        let repo_b = root.join("hosted").join("VetCoders").join("loctree");
734        for r in [&repo_a, &repo_b] {
735            fs::create_dir_all(r).unwrap();
736            Command::new("git").arg("init").arg(r).output().unwrap();
737        }
738
739        let cwd_a = repo_a.to_string_lossy().to_string();
740        let cwd_b = repo_b.to_string_lossy().to_string();
741        let entries = vec![
742            entry(
743                (2026, 3, 21, 9, 0, 0),
744                "sess-1",
745                "user",
746                "Please inspect ai-contexters before editing.",
747                Some(&cwd_a),
748            ),
749            entry(
750                (2026, 3, 21, 9, 1, 0),
751                "sess-1",
752                "assistant",
753                "I found the store seam.",
754                Some(&cwd_a),
755            ),
756            entry(
757                (2026, 3, 21, 9, 2, 0),
758                "sess-1",
759                "user",
760                "Switch to loctree now.",
761                Some(&cwd_b),
762            ),
763            entry(
764                (2026, 3, 21, 9, 3, 0),
765                "sess-1",
766                "assistant",
767                "Reviewing loctree next.",
768                Some(&cwd_b),
769            ),
770        ];
771
772        let segments = semantic_segments(&entries);
773        assert_eq!(segments.len(), 2);
774        assert_eq!(segments[0].project_label(), "VetCoders/ai-contexters");
775        assert_eq!(segments[1].project_label(), "VetCoders/loctree");
776
777        let _ = fs::remove_dir_all(&root);
778    }
779
780    #[test]
781    fn repo_signal_segmentation_keeps_unknown_prefix_honest() {
782        // First half of the session has no cwd (no identity); the user then
783        // starts working in a real on-disk repo. Segmentation must split:
784        // a no-identity prefix (preserved as plan/setup talk) followed by
785        // a properly-owned segment once cwd lands. Previously this was
786        // exercised by a text URL mention; that path is no longer a signal.
787        let root = mk_tmp_dir("unknown-prefix-then-cwd");
788        let repo = root.join("hosted").join("VetCoders").join("ai-contexters");
789        fs::create_dir_all(&repo).unwrap();
790        Command::new("git").arg("init").arg(&repo).output().unwrap();
791
792        let cwd = repo.to_string_lossy().to_string();
793        let entries = vec![
794            entry(
795                (2026, 3, 21, 9, 0, 0),
796                "sess-2",
797                "user",
798                "Need a migration plan but I have not named the repo yet.",
799                None,
800            ),
801            entry(
802                (2026, 3, 21, 9, 1, 0),
803                "sess-2",
804                "assistant",
805                "Drafting a migration plan with acceptance criteria.",
806                None,
807            ),
808            entry(
809                (2026, 3, 21, 9, 2, 0),
810                "sess-2",
811                "user",
812                "Now working in the repo on disk.",
813                Some(&cwd),
814            ),
815        ];
816
817        let segments = semantic_segments(&entries);
818        assert_eq!(segments.len(), 2);
819        assert!(segments[0].repo.is_none());
820        assert_eq!(segments[0].kind, Kind::Plans);
821        assert_eq!(segments[1].project_label(), "VetCoders/ai-contexters");
822
823        let _ = fs::remove_dir_all(&root);
824    }
825
826    #[test]
827    fn repo_signal_segmentation_ignores_gemini_hash_like_cwd() {
828        let entry = entry(
829            (2026, 3, 21, 9, 0, 0),
830            "sess-3",
831            "user",
832            "No trustworthy repo here.",
833            Some("57cfd37b3a72d995c4f2d018ebf9d5a2"),
834        );
835
836        assert!(infer_repo_identity_from_entry(&entry).is_none());
837        let segments = semantic_segments(&[entry]);
838        assert_eq!(segments.len(), 1);
839        assert!(segments[0].repo.is_none());
840    }
841
842    #[test]
843    fn repo_signal_segmentation_uses_local_git_remote_when_available() {
844        let root = mk_tmp_dir("git-remote");
845        let repo = root.join("hosted").join("VetCoders").join("ai-contexters");
846        fs::create_dir_all(&repo).unwrap();
847
848        Command::new("git")
849            .arg("init")
850            .arg(&repo)
851            .output()
852            .expect("git init should run");
853        Command::new("git")
854            .arg("-C")
855            .arg(&repo)
856            .args([
857                "remote",
858                "add",
859                "origin",
860                "git@github.com:VetCoders/ai-contexters.git",
861            ])
862            .output()
863            .expect("git remote add should run");
864
865        let entry = entry(
866            (2026, 3, 21, 9, 0, 0),
867            "sess-4",
868            "user",
869            "Inspect the repo on disk.",
870            Some(repo.to_string_lossy().as_ref()),
871        );
872
873        let repo_identity = infer_repo_identity_from_entry(&entry).expect("repo identity");
874        assert_eq!(repo_identity.slug(), "VetCoders/ai-contexters");
875
876        let _ = fs::remove_dir_all(&root);
877    }
878
879    // ================================================================
880    // Source tier tests
881    // ================================================================
882
883    // (Removed `source_tier_github_url_is_primary` — superseded by
884    //  `entry_identity_ignores_text_url_mentions` /
885    //  `resolve_bucket_still_surfaces_text_url_mentions` below.)
886
887    #[test]
888    fn cwd_git_identity_wins_over_content_mentions() {
889        let root = mk_tmp_dir("cwd-wins");
890        let repo = root.join("Git").join("vista");
891        fs::create_dir_all(&repo).unwrap();
892
893        Command::new("git")
894            .arg("init")
895            .arg(&repo)
896            .output()
897            .expect("git init");
898
899        let e = entry(
900            (2026, 5, 6, 10, 0, 0),
901            "sess-cwd-wins",
902            "user",
903            "We need to inspect https://github.com/RustCrypto/RSA while working locally.",
904            Some(repo.to_string_lossy().as_ref()),
905        );
906
907        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
908        assert_eq!(resolution.bucket, "local/vista");
909        assert_eq!(resolution.source, BucketingSource::CwdGitRoot);
910
911        let _ = fs::remove_dir_all(&root);
912    }
913
914    #[test]
915    fn rejects_template_literals() {
916        assert!(!is_probably_repo_name("{target_owner}"));
917        assert!(!is_probably_repo_name("<YOUR_USERNAME>"));
918        assert!(!is_probably_repo_name("${RELEASE_REPO}"));
919        assert!(!is_probably_repo_name("$REPO"));
920        assert!(!is_probably_repo_name("{org}"));
921    }
922
923    #[test]
924    fn rejects_dot_only_and_traversal_strings() {
925        assert!(!is_probably_repo_name("..."));
926        assert!(!is_probably_repo_name(".."));
927        assert!(!is_probably_repo_name("."));
928        assert!(!is_probably_repo_name(".../"));
929        assert!(!is_probably_repo_name("..hidden"));
930    }
931
932    #[test]
933    fn rejects_control_chars_and_separators() {
934        assert!(!is_probably_repo_name("foo/bar"));
935        assert!(!is_probably_repo_name("foo\\bar"));
936        assert!(!is_probably_repo_name("foo\nbar"));
937        assert!(!is_probably_repo_name("foo bar"));
938        assert!(!is_probably_repo_name(""));
939    }
940
941    #[test]
942    fn accepts_real_repo_names() {
943        assert!(is_probably_repo_name("vibecrafted"));
944        assert!(is_probably_repo_name("rust-memex"));
945        assert!(is_probably_repo_name("ai-contexters"));
946        assert!(is_probably_repo_name("vc-runtime"));
947        assert!(is_probably_repo_name("CodeScribe"));
948        assert!(is_probably_repo_name("starship"));
949        assert!(is_probably_repo_name("01mf02"));
950        assert!(is_probably_repo_name("a"));
951    }
952
953    #[test]
954    fn fallback_routes_invalid_remote_owner_to_local_bucket() {
955        // After round-2, text URLs no longer feed segment identity, so this
956        // test exercises the lower-level `resolve_bucket` API instead. The
957        // malformed-owner → `local/<repo>` rule still belongs to the parser
958        // (anything routing through `infer_repo_identity_from_remote_like`
959        // must not silently materialize a `{placeholder}/...` org).
960        let e = entry(
961            (2026, 3, 22, 10, 0, 0),
962            "sess-local-fallback",
963            "user",
964            "Clone https://github.com/{target_owner}/vibecrafted.git before release.",
965            None,
966        );
967        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
968        assert_eq!(resolution.source, BucketingSource::ContentMention);
969        assert_eq!(resolution.bucket, "local/vibecrafted");
970        assert_ne!(resolution.bucket, "{target_owner}/vibecrafted");
971
972        // Segment pipeline ignores text mentions entirely → non-assertable.
973        let segments = semantic_segments(&[e]);
974        assert_eq!(segments.len(), 1);
975        assert!(segments[0].repo.is_none());
976        assert!(!segments[0].has_assertable_identity());
977    }
978
979    #[test]
980    fn fallback_routes_invalid_remote_repo_to_unknown_local_bucket() {
981        let identity = infer_repo_identity_from_remote_like(
982            "https://github.com/VetCoders/${RELEASE_REPO}.git",
983        )
984        .expect("malformed repository should resolve to local unknown fallback");
985
986        assert_eq!(identity.slug(), "local/unknown");
987    }
988
989    #[test]
990    fn source_tier_git_remote_cwd_is_primary() {
991        let root = mk_tmp_dir("tier-git-remote");
992        let repo = root.join("hosted").join("VetCoders").join("loctree");
993        fs::create_dir_all(&repo).unwrap();
994
995        Command::new("git")
996            .arg("init")
997            .arg(&repo)
998            .output()
999            .expect("git init");
1000        Command::new("git")
1001            .arg("-C")
1002            .arg(&repo)
1003            .args([
1004                "remote",
1005                "add",
1006                "origin",
1007                "git@github.com:VetCoders/loctree.git",
1008            ])
1009            .output()
1010            .expect("git remote add");
1011
1012        let e = entry(
1013            (2026, 3, 22, 10, 0, 0),
1014            "sess-tier-git",
1015            "user",
1016            "Working in the repo.",
1017            Some(repo.to_string_lossy().as_ref()),
1018        );
1019
1020        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default())
1021            .expect("should resolve");
1022        assert_eq!(tiered.tier, SourceTier::Primary);
1023        assert_eq!(tiered.identity.slug(), "VetCoders/loctree");
1024
1025        let _ = fs::remove_dir_all(&root);
1026    }
1027
1028    #[test]
1029    fn source_tier_known_layout_without_git_is_fallback() {
1030        let e = entry(
1031            (2026, 3, 22, 10, 0, 0),
1032            "sess-tier-layout",
1033            "user",
1034            "Working at /nonexistent/hosted/SomeOrg/SomeRepo",
1035            None,
1036        );
1037        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default());
1038        // Path in message text resolved via known layout (no .git) → Fallback
1039        if let Some(t) = tiered {
1040            assert_eq!(t.tier, SourceTier::Fallback);
1041            assert!(!t.tier.is_assertable());
1042        }
1043        // It's also OK if it returns None (path doesn't exist on disk)
1044    }
1045
1046    #[test]
1047    fn source_tier_hex_hash_cwd_is_opaque_without_registry() {
1048        let e = entry(
1049            (2026, 3, 22, 10, 0, 0),
1050            "sess-tier-hash",
1051            "user",
1052            "Hello from Gemini.",
1053            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1054        );
1055        let tiered = infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default());
1056        assert!(
1057            tiered.is_none(),
1058            "hex hash without registry must not resolve"
1059        );
1060    }
1061
1062    #[test]
1063    fn source_tier_hex_hash_resolves_through_registry() {
1064        let root = mk_tmp_dir("tier-registry");
1065        let repo = root.join("hosted").join("VetCoders").join("ai-contexters");
1066        fs::create_dir_all(&repo).unwrap();
1067
1068        Command::new("git")
1069            .arg("init")
1070            .arg(&repo)
1071            .output()
1072            .expect("git init");
1073        Command::new("git")
1074            .arg("-C")
1075            .arg(&repo)
1076            .args([
1077                "remote",
1078                "add",
1079                "origin",
1080                "git@github.com:VetCoders/ai-contexters.git",
1081            ])
1082            .output()
1083            .expect("git remote add");
1084
1085        let mut registry = ProjectHashRegistry::default();
1086        registry.mappings.insert(
1087            "fef6ad02174d592d21e7f8a6143564388027ec0c".to_string(),
1088            repo.to_string_lossy().to_string(),
1089        );
1090
1091        let e = entry(
1092            (2026, 3, 22, 10, 0, 0),
1093            "sess-tier-reg",
1094            "user",
1095            "Hello from Gemini.",
1096            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1097        );
1098
1099        let tiered =
1100            infer_tiered_identity_from_entry(&e, &registry).expect("registry should resolve");
1101        assert_eq!(tiered.tier, SourceTier::Secondary);
1102        assert_eq!(tiered.identity.slug(), "VetCoders/ai-contexters");
1103        assert!(tiered.tier.is_assertable());
1104
1105        let _ = fs::remove_dir_all(&root);
1106    }
1107
1108    #[test]
1109    fn source_tier_registry_with_unknown_hash_returns_none() {
1110        let registry = ProjectHashRegistry::default();
1111        let e = entry(
1112            (2026, 3, 22, 10, 0, 0),
1113            "sess-tier-unknown",
1114            "user",
1115            "Hello from Gemini.",
1116            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
1117        );
1118        let tiered = infer_tiered_identity_from_entry(&e, &registry);
1119        assert!(
1120            tiered.is_none(),
1121            "unknown hash must not resolve even with empty registry"
1122        );
1123    }
1124
1125    #[test]
1126    fn source_tier_classify_cwd_empty_is_opaque() {
1127        assert_eq!(classify_cwd_tier(None), SourceTier::Opaque);
1128        assert_eq!(classify_cwd_tier(Some("")), SourceTier::Opaque);
1129    }
1130
1131    #[test]
1132    fn source_tier_classify_cwd_hex_is_opaque() {
1133        assert_eq!(
1134            classify_cwd_tier(Some("57cfd37b3a72d995c4f2d018ebf9d5a2")),
1135            SourceTier::Opaque
1136        );
1137    }
1138
1139    #[test]
1140    fn segments_carry_source_tier() {
1141        // Source-tier propagation is now demonstrated through cwd-derived
1142        // identity (the only entry-level identity path). Text URL mentions
1143        // no longer produce a Fallback-tier segment.
1144        let root = mk_tmp_dir("segments-carry-tier");
1145        let repo = root.join("hosted").join("VetCoders").join("ai-contexters");
1146        fs::create_dir_all(&repo).unwrap();
1147        Command::new("git").arg("init").arg(&repo).output().unwrap();
1148
1149        let cwd = repo.to_string_lossy().to_string();
1150        let entries = vec![
1151            entry(
1152                (2026, 3, 22, 10, 0, 0),
1153                "sess-st",
1154                "user",
1155                "Working on ai-contexters.",
1156                Some(&cwd),
1157            ),
1158            entry(
1159                (2026, 3, 22, 10, 1, 0),
1160                "sess-st",
1161                "assistant",
1162                "Reviewing now.",
1163                Some(&cwd),
1164            ),
1165        ];
1166
1167        let segments = semantic_segments(&entries);
1168        assert_eq!(segments.len(), 1);
1169        assert_eq!(segments[0].source_tier, Some(SourceTier::Secondary));
1170        assert!(segments[0].has_assertable_identity());
1171
1172        let _ = fs::remove_dir_all(&root);
1173    }
1174
1175    #[test]
1176    fn segments_without_repo_have_no_tier() {
1177        let entries = vec![entry(
1178            (2026, 3, 22, 10, 0, 0),
1179            "sess-none",
1180            "user",
1181            "Just chatting, no repo context.",
1182            None,
1183        )];
1184
1185        let segments = semantic_segments(&entries);
1186        assert_eq!(segments.len(), 1);
1187        assert!(segments[0].repo.is_none());
1188        assert!(segments[0].source_tier.is_none());
1189        assert!(!segments[0].has_assertable_identity());
1190    }
1191
1192    #[test]
1193    fn segments_opaque_cwd_routes_to_non_repo() {
1194        let entries = vec![entry(
1195            (2026, 3, 22, 10, 0, 0),
1196            "sess-opaque",
1197            "user",
1198            "Gemini session with opaque hash only.",
1199            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1200        )];
1201
1202        let segments = semantic_segments(&entries);
1203        assert_eq!(segments.len(), 1);
1204        assert!(segments[0].repo.is_none());
1205        assert_eq!(segments[0].project_label(), "non-repository-contexts");
1206    }
1207
1208    #[test]
1209    fn segments_opaque_cwd_resolves_with_registry() {
1210        let root = mk_tmp_dir("seg-registry");
1211        let repo = root.join("hosted").join("VetCoders").join("ai-contexters");
1212        fs::create_dir_all(&repo).unwrap();
1213
1214        Command::new("git")
1215            .arg("init")
1216            .arg(&repo)
1217            .output()
1218            .expect("git init");
1219        Command::new("git")
1220            .arg("-C")
1221            .arg(&repo)
1222            .args([
1223                "remote",
1224                "add",
1225                "origin",
1226                "git@github.com:VetCoders/ai-contexters.git",
1227            ])
1228            .output()
1229            .expect("git remote add");
1230
1231        let mut registry = ProjectHashRegistry::default();
1232        registry.mappings.insert(
1233            "fef6ad02174d592d21e7f8a6143564388027ec0c".to_string(),
1234            repo.to_string_lossy().to_string(),
1235        );
1236
1237        let entries = vec![entry(
1238            (2026, 3, 22, 10, 0, 0),
1239            "sess-reg",
1240            "user",
1241            "Gemini session with mapped hash.",
1242            Some("fef6ad02174d592d21e7f8a6143564388027ec0c"),
1243        )];
1244
1245        let segments = semantic_segments_with_registry(&entries, &registry);
1246        assert_eq!(segments.len(), 1);
1247        assert!(segments[0].repo.is_some());
1248        assert_eq!(segments[0].source_tier, Some(SourceTier::Secondary));
1249        assert_eq!(segments[0].project_label(), "VetCoders/ai-contexters");
1250
1251        let _ = fs::remove_dir_all(&root);
1252    }
1253
1254    #[test]
1255    fn project_hash_registry_roundtrip() {
1256        let root = mk_tmp_dir("registry-roundtrip");
1257        fs::create_dir_all(&root).unwrap();
1258        let path = root.join("gemini-project-map.json");
1259
1260        let mut registry = ProjectHashRegistry::default();
1261        registry.mappings.insert(
1262            "abc123".to_string(),
1263            "/home/user/repos/my-project".to_string(),
1264        );
1265
1266        let json = serde_json::to_string_pretty(&registry).unwrap();
1267        fs::write(&path, &json).unwrap();
1268
1269        let loaded = ProjectHashRegistry::load_from(&path);
1270        assert_eq!(loaded.mappings.len(), 1);
1271        assert_eq!(
1272            loaded.mappings.get("abc123").map(String::as_str),
1273            Some("/home/user/repos/my-project")
1274        );
1275
1276        let _ = fs::remove_dir_all(&root);
1277    }
1278
1279    #[test]
1280    fn project_hash_registry_missing_file_returns_empty() {
1281        let registry = ProjectHashRegistry::load_from(Path::new("/nonexistent/path.json"));
1282        assert!(registry.mappings.is_empty());
1283    }
1284
1285    #[test]
1286    fn source_tier_ordering() {
1287        assert!(SourceTier::Primary < SourceTier::Secondary);
1288        assert!(SourceTier::Secondary < SourceTier::Fallback);
1289        assert!(SourceTier::Fallback < SourceTier::Opaque);
1290    }
1291
1292    #[test]
1293    fn source_tier_assertable_boundaries() {
1294        assert!(SourceTier::Primary.is_assertable());
1295        assert!(SourceTier::Secondary.is_assertable());
1296        assert!(!SourceTier::Fallback.is_assertable());
1297        assert!(!SourceTier::Opaque.is_assertable());
1298    }
1299
1300    #[test]
1301    fn infer_repo_identity_from_known_layout_matches_hosted() {
1302        let path = Path::new("/Users/x/hosted/MyOrg/my-repo/src/lib.rs");
1303        let id = infer_repo_identity_from_known_layout(path).expect("hosted match");
1304        assert_eq!(id.organization, "MyOrg");
1305        assert_eq!(id.repository, "my-repo");
1306    }
1307
1308    #[test]
1309    fn infer_repo_identity_from_known_layout_matches_repos() {
1310        // Previously dead code: `?` inside the loop returned from the function
1311        // on the first missing marker, so only "hosted" was ever tried.
1312        let path = Path::new("/Users/x/repos/MyOrg/my-repo/src/lib.rs");
1313        let id = infer_repo_identity_from_known_layout(path).expect("repos match");
1314        assert_eq!(id.organization, "MyOrg");
1315        assert_eq!(id.repository, "my-repo");
1316    }
1317
1318    #[test]
1319    fn infer_repo_identity_from_known_layout_matches_repositories() {
1320        let path = Path::new("/Users/x/repositories/MyOrg/my-repo/file.txt");
1321        let id = infer_repo_identity_from_known_layout(path).expect("repositories match");
1322        assert_eq!(id.organization, "MyOrg");
1323        assert_eq!(id.repository, "my-repo");
1324    }
1325
1326    #[test]
1327    fn infer_repo_identity_from_known_layout_matches_github() {
1328        let path = Path::new("/home/user/github/MyOrg/my-repo/src");
1329        let id = infer_repo_identity_from_known_layout(path).expect("github match");
1330        assert_eq!(id.organization, "MyOrg");
1331        assert_eq!(id.repository, "my-repo");
1332    }
1333
1334    #[test]
1335    fn infer_repo_identity_from_known_layout_matches_git() {
1336        let path = Path::new("/home/user/git/MyOrg/my-repo");
1337        let id = infer_repo_identity_from_known_layout(path).expect("git match");
1338        assert_eq!(id.organization, "MyOrg");
1339        assert_eq!(id.repository, "my-repo");
1340    }
1341
1342    #[test]
1343    fn infer_repo_identity_from_known_layout_returns_none_when_no_marker() {
1344        let path = Path::new("/tmp/scratch/work/file.txt");
1345        assert!(infer_repo_identity_from_known_layout(path).is_none());
1346    }
1347
1348    #[test]
1349    fn infer_repo_identity_from_known_layout_rejects_non_repo_name_segments() {
1350        // The `is_probably_repo_name` guard still applies after the loop fix.
1351        // Org/repo segments must be alphanumeric + `.-_` only.
1352        let path = Path::new("/Users/x/repos/My Org With Spaces/my-repo");
1353        assert!(infer_repo_identity_from_known_layout(path).is_none());
1354    }
1355
1356    // ================================================================
1357    // Identity-leak regression: text mentions must not assert ownership
1358    // ================================================================
1359
1360    #[test]
1361    fn known_layout_marker_matches_case_insensitive() {
1362        // Mac convention `/Users/<u>/Git/...` (capital G) must match the
1363        // lowercase `git` marker. Previously case-sensitive comparison rejected
1364        // it, sending identity inference into the now-removed text fallback.
1365        let path = Path::new("/Users/test-user/Git/VetCoders/ai-contexters/src/lib.rs");
1366        let id = infer_repo_identity_from_known_layout(path).expect("Git (capital) matches");
1367        assert_eq!(id.organization, "VetCoders");
1368        assert_eq!(id.repository, "ai-contexters");
1369
1370        // Mixed-case markers also accepted (defensive).
1371        let mixed = Path::new("/srv/Hosted/OrgA/repo-x");
1372        let id_mixed = infer_repo_identity_from_known_layout(mixed).expect("Hosted match");
1373        assert_eq!(id_mixed.organization, "OrgA");
1374        assert_eq!(id_mixed.repository, "repo-x");
1375    }
1376
1377    #[test]
1378    fn text_mention_with_disk_path_no_longer_resolves_to_assertable_tier() {
1379        // Regression: previously, a text mention containing any absolute path
1380        // that walked to a real `.git` on disk produced a Primary/Secondary
1381        // tier via `git remote get-url origin`. That leaked ownership from
1382        // local-clone-folder-name → remote-URL repo (e.g. cwd `vista-codex`
1383        // with chunk mentioning `/Users/.../ai-collaborators/.git/...` and that
1384        // repo's remote pointing to `Szowesgad/maciej-almanach.git`).
1385        //
1386        // After fix: `infer_tiered_identity_from_text` only reads `https://github.com/X/Y`
1387        // URL mentions and clamps the tier to Fallback. Path mentions are ignored.
1388        let root = mk_tmp_dir("text-path-no-leak");
1389        let real_git_repo = root.join("hosted").join("EvilOrg").join("evil-repo");
1390        fs::create_dir_all(&real_git_repo).unwrap();
1391        Command::new("git")
1392            .arg("init")
1393            .arg(&real_git_repo)
1394            .output()
1395            .expect("git init");
1396        Command::new("git")
1397            .arg("-C")
1398            .arg(&real_git_repo)
1399            .args([
1400                "remote",
1401                "add",
1402                "origin",
1403                "git@github.com:EvilOrg/evil-repo.git",
1404            ])
1405            .output()
1406            .expect("git remote add");
1407
1408        let chunk_text = format!(
1409            "Scanning empty directories. Found: {} (this is just a mention)",
1410            real_git_repo.display()
1411        );
1412        let e = entry(
1413            (2026, 5, 19, 10, 0, 0),
1414            "sess-leak",
1415            "user",
1416            &chunk_text,
1417            None,
1418        );
1419
1420        // No identity at all — text path mentions are no longer FS-validated.
1421        assert!(infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default()).is_none());
1422
1423        let _ = fs::remove_dir_all(&root);
1424    }
1425
1426    #[test]
1427    fn entry_identity_ignores_text_url_mentions() {
1428        // Round 2: even URL mentions are no longer entry identity. Promoting
1429        // them produced segment.repo smear and split sessions on context_switch
1430        // when current cwd-derived identity differed from a chunk's mention.
1431        let e = entry(
1432            (2026, 5, 19, 10, 0, 0),
1433            "sess-url",
1434            "user",
1435            "See https://github.com/VetCoders/aicx for context.",
1436            None,
1437        );
1438        assert!(
1439            infer_tiered_identity_from_entry(&e, &ProjectHashRegistry::default()).is_none(),
1440            "entry-level identity must come from cwd/registry only"
1441        );
1442    }
1443
1444    #[test]
1445    fn resolve_bucket_still_surfaces_text_url_mentions() {
1446        // resolve_bucket is a standalone bucket-resolver (separate from segment
1447        // pipeline). Text URL mentions remain a ContentMention signal so future
1448        // search-hint use cases can read them — but they do NOT feed segment
1449        // identity. This test guards that contract.
1450        let e = entry(
1451            (2026, 5, 19, 10, 0, 0),
1452            "sess-bucket",
1453            "user",
1454            "Read https://github.com/VetCoders/aicx and tell me what you see.",
1455            None,
1456        );
1457        let resolution = resolve_bucket(&e, &ProjectHashRegistry::default());
1458        assert_eq!(resolution.source, BucketingSource::ContentMention);
1459        assert_eq!(resolution.bucket, "VetCoders/aicx");
1460    }
1461
1462    #[test]
1463    fn semantic_segment_does_not_split_on_text_url_mention() {
1464        // Round-2 regression: a session whose cwd points at VetCoders/Vista
1465        // (Primary identity, real .git on disk) and whose middle chunk simply
1466        // mentions a different repo URL must stay as ONE segment with the
1467        // owner-correct identity. Pre-fix this split into two segments
1468        // (vetcoders/Vista → VetCoders/aicx Fallback) and the second chunk
1469        // routed away from its real owner.
1470        let root = mk_tmp_dir("segment-no-split-on-url");
1471        let repo = root.join("Git").join("VetCoders").join("vista");
1472        fs::create_dir_all(&repo).unwrap();
1473        Command::new("git").arg("init").arg(&repo).output().unwrap();
1474        Command::new("git")
1475            .arg("-C")
1476            .arg(&repo)
1477            .args([
1478                "remote",
1479                "add",
1480                "origin",
1481                "git@github.com:VetCoders/vista.git",
1482            ])
1483            .output()
1484            .unwrap();
1485
1486        let cwd = repo.to_string_lossy().to_string();
1487        let entries = vec![
1488            entry(
1489                (2026, 5, 19, 10, 0, 0),
1490                "sess-no-split",
1491                "user",
1492                "Start working on Vista.",
1493                Some(&cwd),
1494            ),
1495            entry(
1496                (2026, 5, 19, 10, 1, 0),
1497                "sess-no-split",
1498                "assistant",
1499                "See https://github.com/VetCoders/aicx for the shared parser.",
1500                None,
1501            ),
1502            entry(
1503                (2026, 5, 19, 10, 2, 0),
1504                "sess-no-split",
1505                "user",
1506                "Back to Vista.",
1507                Some(&cwd),
1508            ),
1509        ];
1510
1511        let segments = semantic_segments(&entries);
1512        assert_eq!(
1513            segments.len(),
1514            1,
1515            "single-owner session must not split on a text URL mention"
1516        );
1517        assert_eq!(segments[0].project_label(), "VetCoders/vista");
1518        assert!(segments[0].has_assertable_identity());
1519
1520        let _ = fs::remove_dir_all(&root);
1521    }
1522
1523    #[test]
1524    fn is_probably_repo_name_rejects_date_patterns() {
1525        // Live bug: pseudo-projects like `CodeScribe/2026-01-22` made it into
1526        // ~/.aicx/store/. Block all three date shapes used by known layouts.
1527        assert!(!is_probably_repo_name("2026-01-22"));
1528        assert!(!is_probably_repo_name("2026_01_22"));
1529        assert!(!is_probably_repo_name("2026_0122"));
1530        // Sanity: real-shaped repo names with a date suffix still pass.
1531        assert!(is_probably_repo_name("release-2026"));
1532        assert!(is_probably_repo_name("v2026.01"));
1533        assert!(is_probably_repo_name("aicx"));
1534        assert!(is_probably_repo_name("ai-contexters"));
1535    }
1536
1537    #[test]
1538    fn looks_like_date_pattern_recognizes_three_shapes() {
1539        assert!(looks_like_date_pattern("2026-01-22"));
1540        assert!(looks_like_date_pattern("2026_01_22"));
1541        assert!(looks_like_date_pattern("2026_0122"));
1542        // Out-of-range digits are still date-shaped — intent is what matters.
1543        assert!(looks_like_date_pattern("9999-99-99"));
1544        // Negative cases: wrong length, wrong separators, mixed separators.
1545        assert!(!looks_like_date_pattern("2026-0122")); // 9 chars but wrong sep at idx 4
1546        assert!(!looks_like_date_pattern("2026-01_22")); // mixed - and _
1547        assert!(!looks_like_date_pattern("202601-22")); // missing sep at idx 4
1548        assert!(!looks_like_date_pattern("v2026-01-22")); // extra prefix
1549        assert!(!looks_like_date_pattern("aicx"));
1550    }
1551
1552    #[test]
1553    fn semantic_segment_text_only_path_stays_non_assertable() {
1554        // End-to-end: a session with no cwd but with chunks mentioning real
1555        // on-disk paths must NOT produce an assertable segment. Such segments
1556        // route to non-repository-contexts in store, not the canonical bucket.
1557        let root = mk_tmp_dir("segment-text-only");
1558        let real_git_repo = root.join("Git").join("OrgX").join("repo-y");
1559        fs::create_dir_all(&real_git_repo).unwrap();
1560        Command::new("git")
1561            .arg("init")
1562            .arg(&real_git_repo)
1563            .output()
1564            .expect("git init");
1565
1566        let chunk_text = format!("Inspecting {}", real_git_repo.display());
1567        let entries = vec![
1568            entry(
1569                (2026, 5, 19, 10, 0, 0),
1570                "sess-e2e",
1571                "user",
1572                &chunk_text,
1573                None,
1574            ),
1575            entry(
1576                (2026, 5, 19, 10, 1, 0),
1577                "sess-e2e",
1578                "assistant",
1579                "Found some files.",
1580                None,
1581            ),
1582        ];
1583
1584        let segments = semantic_segments(&entries);
1585        assert_eq!(segments.len(), 1);
1586        assert!(
1587            !segments[0].has_assertable_identity(),
1588            "text-only path mention must never produce assertable identity"
1589        );
1590
1591        let _ = fs::remove_dir_all(&root);
1592    }
1593
1594    #[test]
1595    fn project_hash_registry_load_default_honors_aicx_home_then_falls_back_to_home() {
1596        // The registry path was hard-coded to `$HOME/.aicx/...` and
1597        // ignored the operator's `AICX_HOME` override. Confirm the
1598        // helper now honors AICX_HOME first, falls back to HOME/.aicx,
1599        // and returns an empty registry (not panic) when neither env
1600        // var resolves to an existing file.
1601        let aicx_home = mk_tmp_dir("registry-aicx-home");
1602        fs::create_dir_all(&aicx_home).unwrap();
1603        let map_path = aicx_home.join("gemini-project-map.json");
1604        fs::write(
1605            &map_path,
1606            r#"{ "mappings": { "abc123": "/tmp/aicx-test-registry-target" } }"#,
1607        )
1608        .unwrap();
1609
1610        // Serialize env mutation so we don't fight other tests that
1611        // also touch HOME/AICX_HOME — the segmentation module owns no
1612        // other env-touching tests today, but this guard is cheap.
1613        let prev_aicx_home = std::env::var_os("AICX_HOME");
1614        let prev_home = std::env::var_os("HOME");
1615        // SAFETY: This is a single-threaded test and we restore the
1616        // previous values before returning.
1617        unsafe {
1618            std::env::set_var("AICX_HOME", &aicx_home);
1619        }
1620
1621        let loaded = ProjectHashRegistry::load_default();
1622        assert_eq!(
1623            loaded.mappings.get("abc123").map(String::as_str),
1624            Some("/tmp/aicx-test-registry-target"),
1625            "AICX_HOME-rooted registry must load via load_default"
1626        );
1627
1628        // Falling back to HOME/.aicx when AICX_HOME is unset.
1629        let home_root = mk_tmp_dir("registry-home-fallback");
1630        let home_aicx = home_root.join(".aicx");
1631        fs::create_dir_all(&home_aicx).unwrap();
1632        fs::write(
1633            home_aicx.join("gemini-project-map.json"),
1634            r#"{ "mappings": { "def456": "/tmp/aicx-test-registry-home" } }"#,
1635        )
1636        .unwrap();
1637        unsafe {
1638            std::env::remove_var("AICX_HOME");
1639            std::env::set_var("HOME", &home_root);
1640        }
1641        let loaded_home = ProjectHashRegistry::load_default();
1642        assert_eq!(
1643            loaded_home.mappings.get("def456").map(String::as_str),
1644            Some("/tmp/aicx-test-registry-home"),
1645            "HOME/.aicx must remain the fallback when AICX_HOME is unset"
1646        );
1647
1648        // Restore env to whatever the runner had before.
1649        unsafe {
1650            match prev_aicx_home {
1651                Some(v) => std::env::set_var("AICX_HOME", v),
1652                None => std::env::remove_var("AICX_HOME"),
1653            }
1654            match prev_home {
1655                Some(v) => std::env::set_var("HOME", v),
1656                None => std::env::remove_var("HOME"),
1657            }
1658        }
1659        let _ = fs::remove_dir_all(&aicx_home);
1660        let _ = fs::remove_dir_all(&home_root);
1661    }
1662}