1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TieredIdentity {
22 pub identity: RepoIdentity,
23 pub tier: SourceTier,
24}
25
26#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct ProjectHashRegistry {
56 #[serde(default)]
58 pub mappings: HashMap<String, String>,
59}
60
61impl ProjectHashRegistry {
62 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 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 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 semantic_segments_with_registry(entries, &ProjectHashRegistry::load_default())
106}
107
108pub 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
262pub 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 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
296pub 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
394fn infer_tiered_identity_from_text(text: &str) -> Option<TieredIdentity> {
397 infer_repo_identity_from_remote_like(text).map(|identity| TieredIdentity {
403 identity,
404 tier: SourceTier::Fallback,
405 })
406}
407
408pub 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 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 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 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 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 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 if looks_like_date_pattern(value) {
653 return false;
654 }
655
656 true
657}
658
659fn looks_like_date_pattern(value: &str) -> bool {
668 let bytes = value.as_bytes();
669 match bytes.len() {
670 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 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 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 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 #[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 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 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 if let Some(t) = tiered {
1060 assert_eq!(t.tier, SourceTier::Fallback);
1061 assert!(!t.tier.is_assertable());
1062 }
1063 }
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, ®istry).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, ®istry);
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 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, ®istry);
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(®istry).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 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 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 #[test]
1381 fn known_layout_marker_matches_case_insensitive() {
1382 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 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 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 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 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 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 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 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 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 assert!(looks_like_date_pattern("9999-99-99"));
1564 assert!(!looks_like_date_pattern("2026-0122")); assert!(!looks_like_date_pattern("2026-01_22")); assert!(!looks_like_date_pattern("202601-22")); assert!(!looks_like_date_pattern("v2026-01-22")); assert!(!looks_like_date_pattern("aicx"));
1570 }
1571
1572 #[test]
1573 fn semantic_segment_text_only_path_stays_non_assertable() {
1574 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 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 let prev_aicx_home = std::env::var_os("AICX_HOME");
1634 let prev_home = std::env::var_os("HOME");
1635 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 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 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}