1use super::types::RepoLocation;
2use super::types::RepoMapping;
3use crate::config::validation::canonical_reference_instance_key;
4use crate::config::validation::canonical_reference_key;
5use crate::config::validation::normalize_encoded_ref_key_for_identity;
6use crate::git::ref_key::encode_ref_key;
7use crate::repo_identity::RepoIdentity;
8use crate::repo_identity::RepoIdentityKey;
9use crate::repo_identity::parse_url_and_subpath as identity_parse_url_and_subpath;
10use crate::utils::locks::FileLock;
11use crate::utils::paths::sanitize_dir_name;
12use crate::utils::paths::{self};
13use anyhow::Context;
14use anyhow::Result;
15use anyhow::bail;
16use atomicwrites::AllowOverwrite;
17use atomicwrites::AtomicFile;
18use std::io::ErrorKind;
19use std::io::Write;
20use std::path::Component;
21use std::path::Path;
22use std::path::PathBuf;
23
24const REFERENCE_MAPPING_MARKER: &str = "#thoughts-ref=";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum UrlResolutionKind {
29 Exact,
31 CanonicalFallback,
33}
34
35#[derive(Debug, Clone)]
37pub struct ResolvedUrl {
38 pub matched_url: String,
40 pub resolution: UrlResolutionKind,
42 pub location: RepoLocation,
44}
45
46pub struct RepoMappingManager {
47 mapping_path: PathBuf,
48}
49
50fn lock_path_for_mapping_path(mapping_path: &Path) -> PathBuf {
55 let name = mapping_path
56 .file_name()
57 .unwrap_or_default()
58 .to_string_lossy();
59 mapping_path.with_file_name(format!("{name}.lock"))
60}
61
62fn migrate_legacy_repos_json_if_needed(mapping_path: &Path, legacy_path: &Path) -> Result<bool> {
72 let _lock = FileLock::lock_exclusive(lock_path_for_mapping_path(mapping_path))?;
73
74 if mapping_path.exists() || !legacy_path.exists() {
76 return Ok(false);
77 }
78
79 if let Some(parent) = mapping_path.parent() {
80 paths::ensure_dir(parent)?;
81 }
82
83 let bytes = std::fs::read(legacy_path).with_context(|| {
84 format!(
85 "Failed to read legacy repos.json at {}",
86 legacy_path.display()
87 )
88 })?;
89
90 let af = AtomicFile::new(mapping_path, AllowOverwrite);
91 af.write(|f| f.write_all(&bytes))
92 .context("Failed to migrate repos.json from legacy location")?;
93
94 tracing::info!(
95 "Migrated repos.json from {} to {}",
96 legacy_path.display(),
97 mapping_path.display()
98 );
99
100 Ok(true)
101}
102
103impl RepoMappingManager {
104 pub fn new() -> Result<Self> {
105 let mapping_path = paths::get_repo_mapping_path()?;
106
107 if !mapping_path.exists()
109 && let Ok(legacy_path) = paths::get_legacy_repo_mapping_path()
110 && legacy_path.exists()
111 {
112 let _ = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path)?;
114 }
115
116 Ok(Self { mapping_path })
117 }
118
119 fn lock_path(&self) -> PathBuf {
121 lock_path_for_mapping_path(&self.mapping_path)
122 }
123
124 pub fn load(&self) -> Result<RepoMapping> {
125 let contents = match std::fs::read_to_string(&self.mapping_path) {
126 Ok(c) => c,
127 Err(e) if e.kind() == ErrorKind::NotFound => {
128 return Ok(RepoMapping::default());
130 }
131 Err(e) => {
132 return Err(e).context("Failed to read repository mapping file");
133 }
134 };
135
136 serde_json::from_str(&contents).context("Failed to parse repository mapping")
137 }
138
139 pub fn save(&self, mapping: &RepoMapping) -> Result<()> {
140 if let Some(parent) = self.mapping_path.parent() {
142 paths::ensure_dir(parent)?;
143 }
144
145 let json = serde_json::to_string_pretty(mapping)?;
147 let af = AtomicFile::new(&self.mapping_path, AllowOverwrite);
148 af.write(|f| f.write_all(json.as_bytes()))?;
149
150 Ok(())
151 }
152
153 pub fn load_locked(&self) -> Result<(RepoMapping, FileLock)> {
161 let lock = FileLock::lock_exclusive(self.lock_path())?;
162 let mapping = self.load()?;
163 Ok((mapping, lock))
164 }
165
166 pub fn resolve_url_with_details(
170 &self,
171 url: &str,
172 ) -> Result<Option<(ResolvedUrl, Option<String>)>> {
173 let mapping = self.load()?; let (base_url, subpath) = parse_url_and_subpath(url);
175
176 if let Some(loc) = mapping.mappings.get(&base_url) {
178 return Ok(Some((
179 ResolvedUrl {
180 matched_url: base_url,
181 resolution: UrlResolutionKind::Exact,
182 location: loc.clone(),
183 },
184 subpath,
185 )));
186 }
187
188 let target_key = match RepoIdentity::parse(&base_url) {
190 Ok(id) => id.canonical_key(),
191 Err(_) => return Ok(None),
192 };
193
194 let mut matches: Vec<(String, RepoLocation)> = mapping
195 .mappings
196 .iter()
197 .filter_map(|(k, v)| {
198 let (k_base, _) = parse_url_and_subpath(k);
199 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
200 (key == target_key).then(|| (k.clone(), v.clone()))
201 })
202 .collect();
203
204 matches.sort_by(|a, b| a.0.cmp(&b.0));
206
207 if let Some((matched_url, location)) = matches.into_iter().next() {
208 return Ok(Some((
209 ResolvedUrl {
210 matched_url,
211 resolution: UrlResolutionKind::CanonicalFallback,
212 location,
213 },
214 subpath,
215 )));
216 }
217
218 Ok(None)
219 }
220
221 pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
226 if let Some((resolved, subpath)) = self.resolve_url_with_details(url)? {
227 let mut p = resolved.location.path;
228 if let Some(ref sub) = subpath {
229 validate_subpath(sub)?;
230 p = p.join(sub);
231 }
232 return Ok(Some(p));
233 }
234 Ok(None)
235 }
236
237 pub fn resolve_reference_url(
238 &self,
239 url: &str,
240 ref_name: Option<&str>,
241 ) -> Result<Option<PathBuf>> {
242 let mapping = self.load()?;
243 let (_, subpath) = parse_url_and_subpath(url);
244
245 let matches = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
246
247 if let Some(stored_key) = matches.first()
248 && let Some(location) = mapping.mappings.get(stored_key)
249 {
250 let mut p = location.path.clone();
251 if let Some(ref sub) = subpath {
252 validate_subpath(sub)?;
253 p = p.join(sub);
254 }
255 return Ok(Some(p));
256 }
257
258 Ok(None)
259 }
260
261 pub fn add_mapping(&mut self, url: &str, path: PathBuf, auto_managed: bool) -> Result<()> {
267 let _lock = FileLock::lock_exclusive(self.lock_path())?;
268 let mut mapping = self.load()?; if !path.exists() {
272 bail!("Path does not exist: {}", path.display());
273 }
274
275 if !path.is_dir() {
276 bail!("Path is not a directory: {}", path.display());
277 }
278
279 let (base_url, _) = parse_url_and_subpath(url);
280 let new_key = RepoIdentity::parse(&base_url)?.canonical_key();
281
282 let matching_urls: Vec<String> = mapping
284 .mappings
285 .keys()
286 .filter_map(|k| {
287 let (k_base, _) = parse_url_and_subpath(k);
288 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
289 (key == new_key).then(|| k.clone())
290 })
291 .collect();
292
293 let preserved_last_sync = matching_urls
295 .iter()
296 .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
297 .max();
298
299 for k in matching_urls {
301 mapping.mappings.remove(&k);
302 }
303
304 mapping.mappings.insert(
306 base_url,
307 RepoLocation {
308 path,
309 auto_managed,
310 last_sync: preserved_last_sync,
311 },
312 );
313
314 self.save(&mapping)?;
315 Ok(())
316 }
317
318 pub fn add_reference_mapping(
319 &mut self,
320 url: &str,
321 ref_name: Option<&str>,
322 path: PathBuf,
323 auto_managed: bool,
324 ) -> Result<()> {
325 let _lock = FileLock::lock_exclusive(self.lock_path())?;
326 let mut mapping = self.load()?;
327
328 if !path.exists() {
329 bail!("Path does not exist: {}", path.display());
330 }
331
332 if !path.is_dir() {
333 bail!("Path is not a directory: {}", path.display());
334 }
335
336 let storage_key = reference_mapping_storage_key(url, ref_name)?;
337 let matching_urls = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
338
339 let preserved_last_sync = matching_urls
340 .iter()
341 .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
342 .max();
343
344 for k in matching_urls {
345 mapping.mappings.remove(&k);
346 }
347
348 mapping.mappings.insert(
349 storage_key,
350 RepoLocation {
351 path,
352 auto_managed,
353 last_sync: preserved_last_sync,
354 },
355 );
356
357 self.save(&mapping)?;
358 Ok(())
359 }
360
361 pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
364 let _lock = FileLock::lock_exclusive(self.lock_path())?;
365 let mut mapping = self.load()?;
366 mapping.mappings.remove(url);
367 self.save(&mapping)?;
368 Ok(())
369 }
370
371 pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
373 let mapping = self.load()?;
374 Ok(mapping
375 .mappings
376 .get(url)
377 .is_some_and(|loc| loc.auto_managed))
378 }
379
380 pub fn is_reference_auto_managed(&self, url: &str, ref_name: Option<&str>) -> Result<bool> {
381 let mapping = self.load()?;
382 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
383 Ok(keys
384 .first()
385 .and_then(|key| mapping.mappings.get(key))
386 .is_some_and(|loc| loc.auto_managed))
387 }
388
389 pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
393 let home = dirs::home_dir()
394 .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
395
396 let (base_url, _sub) = parse_url_and_subpath(url);
397 let id = RepoIdentity::parse(&base_url)?;
398 let key = id.canonical_key(); let mut p = home
401 .join(".thoughts")
402 .join("clones")
403 .join(sanitize_dir_name(&key.host));
404 for seg in key.org_path.split('/') {
405 if !seg.is_empty() {
406 p = p.join(sanitize_dir_name(seg));
407 }
408 }
409 p = p.join(sanitize_dir_name(&key.repo));
410 Ok(p)
411 }
412
413 pub fn get_default_reference_clone_path(url: &str, ref_name: Option<&str>) -> Result<PathBuf> {
414 let mut path = Self::get_default_clone_path(url)?;
415 if let Some(ref_name) = ref_name {
416 let ref_key = encode_ref_key(ref_name)?;
417 let repo_dir = path
418 .file_name()
419 .ok_or_else(|| anyhow::anyhow!("Default clone path had no repository segment"))?
420 .to_string_lossy()
421 .to_string();
422 path.set_file_name(format!("{}@{}", sanitize_dir_name(&repo_dir), ref_key));
423 }
424 Ok(path)
425 }
426
427 pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
432 let _lock = FileLock::lock_exclusive(self.lock_path())?;
433 let mut mapping = self.load()?;
434 let now = chrono::Utc::now();
435
436 let (base_url, _) = parse_url_and_subpath(url);
438 if let Some(loc) = mapping.mappings.get_mut(&base_url) {
439 loc.last_sync = Some(now);
440 self.save(&mapping)?;
441 return Ok(());
442 }
443
444 let target_key = RepoIdentity::parse(&base_url)?.canonical_key();
447
448 let matched_key: Option<String> = mapping.mappings.keys().find_map(|k| {
452 let (k_base, _) = parse_url_and_subpath(k);
453 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
454 (key == target_key).then(|| k.clone())
455 });
456
457 if let Some(key) = matched_key
458 && let Some(loc) = mapping.mappings.get_mut(&key)
459 {
460 loc.last_sync = Some(now);
461 self.save(&mapping)?;
462 }
463
464 Ok(())
465 }
466
467 pub fn update_reference_sync_time(&mut self, url: &str, ref_name: Option<&str>) -> Result<()> {
468 let _lock = FileLock::lock_exclusive(self.lock_path())?;
469 let mut mapping = self.load()?;
470 let now = chrono::Utc::now();
471
472 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
473 if let Some(key) = keys.first()
474 && let Some(loc) = mapping.mappings.get_mut(key)
475 {
476 loc.last_sync = Some(now);
477 self.save(&mapping)?;
478 }
479
480 Ok(())
481 }
482
483 pub fn get_canonical_key(url: &str) -> Option<RepoIdentityKey> {
485 let (base, _) = parse_url_and_subpath(url);
486 RepoIdentity::parse(&base).ok().map(|id| id.canonical_key())
487 }
488
489 fn matching_reference_storage_keys(
490 mapping: &RepoMapping,
491 url: &str,
492 ref_name: Option<&str>,
493 ) -> Result<Vec<String>> {
494 let wanted = canonical_reference_instance_key(url, ref_name)?;
495 let mut keys: Vec<String> = mapping
496 .mappings
497 .keys()
498 .filter_map(|stored_key| {
499 let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(stored_key);
500 let (host, org_path, repo) = canonical_reference_key(&stored_url).ok()?;
501 let normalized_stored_ref_key = stored_ref_key
502 .as_deref()
503 .map(|ref_key| normalize_encoded_ref_key_for_identity(ref_key).into_owned());
504 let actual = (host, org_path, repo, normalized_stored_ref_key);
505 (actual == wanted).then(|| stored_key.clone())
506 })
507 .collect();
508 keys.sort();
509 Ok(keys)
510 }
511}
512
513fn reference_mapping_storage_key(url: &str, ref_name: Option<&str>) -> Result<String> {
514 let (base_url, _) = parse_url_and_subpath(url);
515 match ref_name {
516 Some(ref_name) => Ok(format!(
517 "{}{REFERENCE_MAPPING_MARKER}{}",
518 base_url,
519 encode_ref_key(ref_name)?
520 )),
521 None => Ok(base_url),
522 }
523}
524
525pub fn parse_reference_mapping_storage_key(stored_key: &str) -> (String, Option<String>) {
526 match stored_key.split_once(REFERENCE_MAPPING_MARKER) {
527 Some((base_url, ref_key)) => (base_url.to_string(), Some(ref_key.to_string())),
528 None => (stored_key.to_string(), None),
529 }
530}
531
532pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
536 identity_parse_url_and_subpath(url)
537}
538
539fn validate_subpath(subpath: &str) -> Result<()> {
544 let path = Path::new(subpath);
545 if path.is_absolute() {
546 bail!("Invalid subpath (must be relative and not contain '..'): {subpath}");
547 }
548 for component in path.components() {
549 match component {
550 Component::ParentDir | Component::Prefix(_) => {
551 bail!("Invalid subpath (must be relative and not contain '..'): {subpath}");
552 }
553 _ => {}
554 }
555 }
556 Ok(())
557}
558
559pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
560 let url = url.trim_end_matches(".git");
561
562 if let Some(pos) = url.rfind('/') {
564 Ok(url[pos + 1..].to_string())
565 } else if let Some(pos) = url.rfind(':') {
566 if let Some(slash_pos) = url[pos + 1..].rfind('/') {
568 Ok(url[pos + 1 + slash_pos + 1..].to_string())
569 } else {
570 Ok(url[pos + 1..].to_string())
571 }
572 } else {
573 bail!("Cannot extract repository name from URL: {url}")
574 }
575}
576
577pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
584 let (base, _) = parse_url_and_subpath(url);
585 let id = RepoIdentity::parse(&base)?;
586 Ok((id.org_path, id.repo))
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use tempfile::TempDir;
593
594 #[test]
595 fn test_parse_url_and_subpath() {
596 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
597 assert_eq!(url, "git@github.com:user/repo.git");
598 assert_eq!(sub, None);
599
600 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
601 assert_eq!(url, "git@github.com:user/repo.git");
602 assert_eq!(sub, Some("docs/api".to_string()));
603
604 let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
605 assert_eq!(url, "https://github.com/user/repo");
606 assert_eq!(sub, None);
607 }
608
609 #[test]
610 fn test_extract_repo_name() {
611 assert_eq!(
612 extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
613 "repo"
614 );
615 assert_eq!(
616 extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
617 "repo"
618 );
619 assert_eq!(
620 extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
621 "repo"
622 );
623 }
624
625 #[test]
626 fn test_extract_org_repo() {
627 assert_eq!(
628 extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
629 ("user".to_string(), "repo".to_string())
630 );
631 assert_eq!(
632 extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
633 ("user".to_string(), "repo".to_string())
634 );
635 assert_eq!(
636 extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
637 ("user".to_string(), "repo".to_string())
638 );
639 assert_eq!(
640 extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
641 .unwrap(),
642 ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
643 );
644 }
645
646 #[test]
647 fn test_default_clone_path_hierarchical() {
648 let p =
650 RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
651 assert!(p.ends_with(std::path::Path::new(".thoughts/clones/github.com/org/repo")));
652 }
653
654 #[test]
655 fn test_default_clone_path_gitlab_subgroups() {
656 let p = RepoMappingManager::get_default_clone_path(
657 "https://gitlab.com/group/subgroup/team/repo.git",
658 )
659 .unwrap();
660 assert!(p.ends_with(std::path::Path::new(
661 ".thoughts/clones/gitlab.com/group/subgroup/team/repo"
662 )));
663 }
664
665 #[test]
666 fn test_default_clone_path_ssh_port() {
667 let p = RepoMappingManager::get_default_clone_path(
668 "ssh://git@myhost.example.com:2222/org/repo.git",
669 )
670 .unwrap();
671 assert!(p.ends_with(std::path::Path::new(
672 ".thoughts/clones/myhost.example.com/org/repo"
673 )));
674 }
675
676 #[test]
677 fn test_default_reference_clone_path_appends_ref_key() {
678 let p = RepoMappingManager::get_default_reference_clone_path(
679 "https://github.com/org/repo.git",
680 Some("refs/tags/v1.2.3"),
681 )
682 .unwrap();
683 assert!(p.ends_with(std::path::Path::new(
684 ".thoughts/clones/github.com/org/repo@r-refs~2ftags~2fv1.2.3"
685 )));
686 }
687
688 #[test]
689 fn test_canonical_key_consistency() {
690 let ssh_key = RepoMappingManager::get_canonical_key("git@github.com:Org/Repo.git").unwrap();
691 let https_key =
692 RepoMappingManager::get_canonical_key("https://github.com/org/repo").unwrap();
693 assert_eq!(
694 ssh_key, https_key,
695 "SSH and HTTPS should have same canonical key"
696 );
697 }
698
699 #[test]
700 fn test_add_reference_mapping_keeps_different_refs_separate() {
701 let temp_dir = TempDir::new().unwrap();
702 let mapping_path = temp_dir.path().join("repos.json");
703 let mut manager = RepoMappingManager { mapping_path };
704
705 let main_path = temp_dir.path().join("repo-main");
706 let tag_path = temp_dir.path().join("repo-tag");
707 std::fs::create_dir_all(&main_path).unwrap();
708 std::fs::create_dir_all(&tag_path).unwrap();
709
710 manager
711 .add_reference_mapping(
712 "https://github.com/org/repo.git",
713 Some("refs/heads/main"),
714 main_path.clone(),
715 true,
716 )
717 .unwrap();
718 manager
719 .add_reference_mapping(
720 "git@github.com:Org/Repo.git",
721 Some("refs/tags/v1.0.0"),
722 tag_path.clone(),
723 true,
724 )
725 .unwrap();
726
727 assert_eq!(
728 manager
729 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
730 .unwrap(),
731 Some(main_path)
732 );
733 assert_eq!(
734 manager
735 .resolve_reference_url("https://github.com/org/repo", Some("refs/tags/v1.0.0"))
736 .unwrap(),
737 Some(tag_path)
738 );
739
740 let mapping = manager.load().unwrap();
741 assert_eq!(mapping.mappings.len(), 2);
742 }
743
744 #[test]
745 fn test_resolve_reference_url_matches_legacy_refs_remotes_and_heads_equivalently() {
746 let temp_dir = TempDir::new().unwrap();
747 let mapping_path = temp_dir.path().join("repos.json");
748 let mut manager = RepoMappingManager {
749 mapping_path: mapping_path.clone(),
750 };
751
752 let repo_path = temp_dir.path().join("repo-legacy");
753 std::fs::create_dir_all(&repo_path).unwrap();
754
755 manager
756 .add_reference_mapping(
757 "https://github.com/org/repo.git",
758 Some("refs/remotes/origin/main"),
759 repo_path.clone(),
760 true,
761 )
762 .unwrap();
763
764 let mgr_ro = RepoMappingManager { mapping_path };
765 assert_eq!(
766 mgr_ro
767 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
768 .unwrap(),
769 Some(repo_path)
770 );
771 }
772
773 #[test]
774 fn test_update_reference_sync_time_updates_ref_specific_entry() {
775 let temp_dir = TempDir::new().unwrap();
776 let mapping_path = temp_dir.path().join("repos.json");
777 let mut manager = RepoMappingManager { mapping_path };
778
779 let repo_path = temp_dir.path().join("repo-main");
780 std::fs::create_dir_all(&repo_path).unwrap();
781
782 manager
783 .add_reference_mapping(
784 "https://github.com/org/repo.git",
785 Some("refs/heads/main"),
786 repo_path,
787 true,
788 )
789 .unwrap();
790
791 manager
792 .update_reference_sync_time("git@github.com:Org/Repo.git", Some("refs/heads/main"))
793 .unwrap();
794
795 let mapping = manager.load().unwrap();
796 let found = mapping
797 .mappings
798 .iter()
799 .find(|(key, _)| key.contains("#thoughts-ref="))
800 .unwrap();
801 assert!(found.1.last_sync.is_some());
802 }
803
804 #[test]
805 fn test_is_reference_auto_managed_matches_ref_specific_entry() {
806 let temp_dir = TempDir::new().unwrap();
807 let mapping_path = temp_dir.path().join("repos.json");
808 let mut manager = RepoMappingManager {
809 mapping_path: mapping_path.clone(),
810 };
811
812 let repo_path = temp_dir.path().join("repo-main");
813 std::fs::create_dir_all(&repo_path).unwrap();
814
815 manager
816 .add_reference_mapping(
817 "https://github.com/org/repo.git",
818 Some("refs/heads/main"),
819 repo_path,
820 true,
821 )
822 .unwrap();
823
824 let mgr_ro = RepoMappingManager { mapping_path };
825 assert!(
826 mgr_ro
827 .is_reference_auto_managed("git@github.com:Org/Repo.git", Some("refs/heads/main"))
828 .unwrap()
829 );
830 }
831
832 #[test]
839 fn test_validate_subpath_accepts_valid_paths() {
840 assert!(validate_subpath("docs").is_ok());
842 assert!(validate_subpath("docs/api").is_ok());
843 assert!(validate_subpath("src/lib/utils").is_ok());
844 assert!(validate_subpath("a/b/c/d/e").is_ok());
845 }
846
847 #[test]
848 fn test_validate_subpath_rejects_parent_dir_traversal() {
849 assert!(validate_subpath("..").is_err());
851 assert!(validate_subpath("../etc").is_err());
852 assert!(validate_subpath("docs/../..").is_err());
853 assert!(validate_subpath("docs/../../etc").is_err());
854 assert!(validate_subpath("a/b/c/../../../etc").is_err());
855 }
856
857 #[test]
858 fn test_validate_subpath_rejects_absolute_paths() {
859 assert!(validate_subpath("/etc").is_err());
861 assert!(validate_subpath("/etc/passwd").is_err());
862 assert!(validate_subpath("/home/user/.ssh").is_err());
863 }
864
865 #[test]
870 fn test_migrate_legacy_when_destination_missing() {
871 let dir = TempDir::new().unwrap();
872 let mapping_path = dir.path().join("repos.json");
873 let legacy_path = dir.path().join("legacy_repos.json");
874
875 let legacy_bytes = br#"{ "mappings": { "git@github.com:a/b.git": { "path": "/tmp/x", "auto_managed": false, "last_sync": null } } }"#;
876 std::fs::write(&legacy_path, legacy_bytes).unwrap();
877
878 let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
879 assert!(migrated);
880 assert!(mapping_path.exists());
881
882 let got = std::fs::read(&mapping_path).unwrap();
883 assert_eq!(got, legacy_bytes);
884 }
885
886 #[test]
887 fn test_migrate_does_not_overwrite_existing_destination() {
888 let dir = TempDir::new().unwrap();
889 let mapping_path = dir.path().join("repos.json");
890 let legacy_path = dir.path().join("legacy_repos.json");
891
892 std::fs::write(&legacy_path, b"legacy").unwrap();
893 std::fs::write(&mapping_path, b"already-there").unwrap();
894
895 let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
896 assert!(!migrated);
897
898 let got = std::fs::read(&mapping_path).unwrap();
899 assert_eq!(got, b"already-there");
900 }
901
902 #[test]
903 fn test_migrate_noop_when_legacy_missing() {
904 let dir = TempDir::new().unwrap();
905 let mapping_path = dir.path().join("repos.json");
906 let legacy_path = dir.path().join("legacy_repos.json"); let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
909 assert!(!migrated);
910 assert!(!mapping_path.exists());
911 }
912
913 #[test]
914 fn test_load_missing_file_returns_default_without_creating_file() {
915 let dir = TempDir::new().unwrap();
916 let mapping_path = dir.path().join("repos.json");
917 let mgr = RepoMappingManager {
918 mapping_path: mapping_path.clone(),
919 };
920
921 let mapping = mgr.load().unwrap();
922 assert_eq!(mapping, RepoMapping::default());
923 assert!(!mapping_path.exists(), "load() must not create repos.json");
924 }
925
926 #[test]
927 fn test_load_reads_existing_file() {
928 let dir = TempDir::new().unwrap();
929 let mapping_path = dir.path().join("repos.json");
930 let mgr = RepoMappingManager { mapping_path };
931
932 let mut m = RepoMapping::default();
933 m.mappings.insert(
934 "git@github.com:org/repo.git".to_string(),
935 RepoLocation {
936 path: PathBuf::from("/tmp/repo"),
937 auto_managed: false,
938 last_sync: None,
939 },
940 );
941 mgr.save(&m).unwrap();
942
943 let loaded = mgr.load().unwrap();
944 assert!(loaded.mappings.contains_key("git@github.com:org/repo.git"));
945 }
946}