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| 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
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 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 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 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 #[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 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 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 if let Some(t) = tiered {
1040 assert_eq!(t.tier, SourceTier::Fallback);
1041 assert!(!t.tier.is_assertable());
1042 }
1043 }
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, ®istry).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, ®istry);
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 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, ®istry);
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(®istry).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 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 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 #[test]
1361 fn known_layout_marker_matches_case_insensitive() {
1362 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 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 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 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 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 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 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 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 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 assert!(looks_like_date_pattern("9999-99-99"));
1544 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"));
1550 }
1551
1552 #[test]
1553 fn semantic_segment_text_only_path_stays_non_assertable() {
1554 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 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 let prev_aicx_home = std::env::var_os("AICX_HOME");
1614 let prev_home = std::env::var_os("HOME");
1615 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 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 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}