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.clone();
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: String, 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: String,
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 #[allow(dead_code)]
363 pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
365 let _lock = FileLock::lock_exclusive(self.lock_path())?;
366 let mut mapping = self.load()?;
367 mapping.mappings.remove(url);
368 self.save(&mapping)?;
369 Ok(())
370 }
371
372 pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
374 let mapping = self.load()?;
375 Ok(mapping
376 .mappings
377 .get(url)
378 .map(|loc| loc.auto_managed)
379 .unwrap_or(false))
380 }
381
382 pub fn is_reference_auto_managed(&self, url: &str, ref_name: Option<&str>) -> Result<bool> {
383 let mapping = self.load()?;
384 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
385 Ok(keys
386 .first()
387 .and_then(|key| mapping.mappings.get(key))
388 .map(|loc| loc.auto_managed)
389 .unwrap_or(false))
390 }
391
392 pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
396 let home = dirs::home_dir()
397 .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
398
399 let (base_url, _sub) = parse_url_and_subpath(url);
400 let id = RepoIdentity::parse(&base_url)?;
401 let key = id.canonical_key(); let mut p = home
404 .join(".thoughts")
405 .join("clones")
406 .join(sanitize_dir_name(&key.host));
407 for seg in key.org_path.split('/') {
408 if !seg.is_empty() {
409 p = p.join(sanitize_dir_name(seg));
410 }
411 }
412 p = p.join(sanitize_dir_name(&key.repo));
413 Ok(p)
414 }
415
416 pub fn get_default_reference_clone_path(url: &str, ref_name: Option<&str>) -> Result<PathBuf> {
417 let mut path = Self::get_default_clone_path(url)?;
418 if let Some(ref_name) = ref_name {
419 let ref_key = encode_ref_key(ref_name)?;
420 let repo_dir = path
421 .file_name()
422 .ok_or_else(|| anyhow::anyhow!("Default clone path had no repository segment"))?
423 .to_string_lossy()
424 .to_string();
425 path.set_file_name(format!("{}@{}", sanitize_dir_name(&repo_dir), ref_key));
426 }
427 Ok(path)
428 }
429
430 pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
435 let _lock = FileLock::lock_exclusive(self.lock_path())?;
436 let mut mapping = self.load()?;
437 let now = chrono::Utc::now();
438
439 let (base_url, _) = parse_url_and_subpath(url);
441 if let Some(loc) = mapping.mappings.get_mut(&base_url) {
442 loc.last_sync = Some(now);
443 self.save(&mapping)?;
444 return Ok(());
445 }
446
447 let target_key = RepoIdentity::parse(&base_url)?.canonical_key();
450
451 let matched_key: Option<String> = mapping
455 .mappings
456 .keys()
457 .filter_map(|k| {
458 let (k_base, _) = parse_url_and_subpath(k);
459 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
460 (key == target_key).then(|| k.clone())
461 })
462 .next();
463
464 if let Some(key) = matched_key
465 && let Some(loc) = mapping.mappings.get_mut(&key)
466 {
467 loc.last_sync = Some(now);
468 self.save(&mapping)?;
469 }
470
471 Ok(())
472 }
473
474 pub fn update_reference_sync_time(&mut self, url: &str, ref_name: Option<&str>) -> Result<()> {
475 let _lock = FileLock::lock_exclusive(self.lock_path())?;
476 let mut mapping = self.load()?;
477 let now = chrono::Utc::now();
478
479 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
480 if let Some(key) = keys.first()
481 && let Some(loc) = mapping.mappings.get_mut(key)
482 {
483 loc.last_sync = Some(now);
484 self.save(&mapping)?;
485 }
486
487 Ok(())
488 }
489
490 pub fn get_canonical_key(url: &str) -> Option<RepoIdentityKey> {
492 let (base, _) = parse_url_and_subpath(url);
493 RepoIdentity::parse(&base).ok().map(|id| id.canonical_key())
494 }
495
496 fn matching_reference_storage_keys(
497 mapping: &RepoMapping,
498 url: &str,
499 ref_name: Option<&str>,
500 ) -> Result<Vec<String>> {
501 let wanted = canonical_reference_instance_key(url, ref_name)?;
502 let mut keys: Vec<String> = mapping
503 .mappings
504 .keys()
505 .filter_map(|stored_key| {
506 let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(stored_key);
507 let (host, org_path, repo) = canonical_reference_key(&stored_url).ok()?;
508 let normalized_stored_ref_key = stored_ref_key
509 .as_deref()
510 .map(|ref_key| normalize_encoded_ref_key_for_identity(ref_key).into_owned());
511 let actual = (host, org_path, repo, normalized_stored_ref_key);
512 (actual == wanted).then(|| stored_key.clone())
513 })
514 .collect();
515 keys.sort();
516 Ok(keys)
517 }
518}
519
520fn reference_mapping_storage_key(url: &str, ref_name: Option<&str>) -> Result<String> {
521 let (base_url, _) = parse_url_and_subpath(url);
522 match ref_name {
523 Some(ref_name) => Ok(format!(
524 "{}{REFERENCE_MAPPING_MARKER}{}",
525 base_url,
526 encode_ref_key(ref_name)?
527 )),
528 None => Ok(base_url),
529 }
530}
531
532pub fn parse_reference_mapping_storage_key(stored_key: &str) -> (String, Option<String>) {
533 match stored_key.split_once(REFERENCE_MAPPING_MARKER) {
534 Some((base_url, ref_key)) => (base_url.to_string(), Some(ref_key.to_string())),
535 None => (stored_key.to_string(), None),
536 }
537}
538
539pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
543 identity_parse_url_and_subpath(url)
544}
545
546fn validate_subpath(subpath: &str) -> Result<()> {
551 let path = Path::new(subpath);
552 if path.is_absolute() {
553 bail!(
554 "Invalid subpath (must be relative and not contain '..'): {}",
555 subpath
556 );
557 }
558 for component in path.components() {
559 match component {
560 Component::ParentDir => {
561 bail!(
562 "Invalid subpath (must be relative and not contain '..'): {}",
563 subpath
564 );
565 }
566 Component::Prefix(_) => {
567 bail!(
568 "Invalid subpath (must be relative and not contain '..'): {}",
569 subpath
570 );
571 }
572 _ => {}
573 }
574 }
575 Ok(())
576}
577
578pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
579 let url = url.trim_end_matches(".git");
580
581 if let Some(pos) = url.rfind('/') {
583 Ok(url[pos + 1..].to_string())
584 } else if let Some(pos) = url.rfind(':') {
585 if let Some(slash_pos) = url[pos + 1..].rfind('/') {
587 Ok(url[pos + 1 + slash_pos + 1..].to_string())
588 } else {
589 Ok(url[pos + 1..].to_string())
590 }
591 } else {
592 bail!("Cannot extract repository name from URL: {}", url)
593 }
594}
595
596pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
603 let (base, _) = parse_url_and_subpath(url);
604 let id = RepoIdentity::parse(&base)?;
605 Ok((id.org_path, id.repo))
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use tempfile::TempDir;
612
613 #[test]
614 fn test_parse_url_and_subpath() {
615 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
616 assert_eq!(url, "git@github.com:user/repo.git");
617 assert_eq!(sub, None);
618
619 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
620 assert_eq!(url, "git@github.com:user/repo.git");
621 assert_eq!(sub, Some("docs/api".to_string()));
622
623 let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
624 assert_eq!(url, "https://github.com/user/repo");
625 assert_eq!(sub, None);
626 }
627
628 #[test]
629 fn test_extract_repo_name() {
630 assert_eq!(
631 extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
632 "repo"
633 );
634 assert_eq!(
635 extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
636 "repo"
637 );
638 assert_eq!(
639 extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
640 "repo"
641 );
642 }
643
644 #[test]
645 fn test_extract_org_repo() {
646 assert_eq!(
647 extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
648 ("user".to_string(), "repo".to_string())
649 );
650 assert_eq!(
651 extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
652 ("user".to_string(), "repo".to_string())
653 );
654 assert_eq!(
655 extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
656 ("user".to_string(), "repo".to_string())
657 );
658 assert_eq!(
659 extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
660 .unwrap(),
661 ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
662 );
663 }
664
665 #[test]
666 fn test_default_clone_path_hierarchical() {
667 let p =
669 RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
670 assert!(p.ends_with(std::path::Path::new(".thoughts/clones/github.com/org/repo")));
671 }
672
673 #[test]
674 fn test_default_clone_path_gitlab_subgroups() {
675 let p = RepoMappingManager::get_default_clone_path(
676 "https://gitlab.com/group/subgroup/team/repo.git",
677 )
678 .unwrap();
679 assert!(p.ends_with(std::path::Path::new(
680 ".thoughts/clones/gitlab.com/group/subgroup/team/repo"
681 )));
682 }
683
684 #[test]
685 fn test_default_clone_path_ssh_port() {
686 let p = RepoMappingManager::get_default_clone_path(
687 "ssh://git@myhost.example.com:2222/org/repo.git",
688 )
689 .unwrap();
690 assert!(p.ends_with(std::path::Path::new(
691 ".thoughts/clones/myhost.example.com/org/repo"
692 )));
693 }
694
695 #[test]
696 fn test_default_reference_clone_path_appends_ref_key() {
697 let p = RepoMappingManager::get_default_reference_clone_path(
698 "https://github.com/org/repo.git",
699 Some("refs/tags/v1.2.3"),
700 )
701 .unwrap();
702 assert!(p.ends_with(std::path::Path::new(
703 ".thoughts/clones/github.com/org/repo@r-refs~2ftags~2fv1.2.3"
704 )));
705 }
706
707 #[test]
708 fn test_canonical_key_consistency() {
709 let ssh_key = RepoMappingManager::get_canonical_key("git@github.com:Org/Repo.git").unwrap();
710 let https_key =
711 RepoMappingManager::get_canonical_key("https://github.com/org/repo").unwrap();
712 assert_eq!(
713 ssh_key, https_key,
714 "SSH and HTTPS should have same canonical key"
715 );
716 }
717
718 #[test]
719 fn test_add_reference_mapping_keeps_different_refs_separate() {
720 let temp_dir = TempDir::new().unwrap();
721 let mapping_path = temp_dir.path().join("repos.json");
722 let mut manager = RepoMappingManager { mapping_path };
723
724 let main_path = temp_dir.path().join("repo-main");
725 let tag_path = temp_dir.path().join("repo-tag");
726 std::fs::create_dir_all(&main_path).unwrap();
727 std::fs::create_dir_all(&tag_path).unwrap();
728
729 manager
730 .add_reference_mapping(
731 "https://github.com/org/repo.git".to_string(),
732 Some("refs/heads/main"),
733 main_path.clone(),
734 true,
735 )
736 .unwrap();
737 manager
738 .add_reference_mapping(
739 "git@github.com:Org/Repo.git".to_string(),
740 Some("refs/tags/v1.0.0"),
741 tag_path.clone(),
742 true,
743 )
744 .unwrap();
745
746 assert_eq!(
747 manager
748 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
749 .unwrap(),
750 Some(main_path)
751 );
752 assert_eq!(
753 manager
754 .resolve_reference_url("https://github.com/org/repo", Some("refs/tags/v1.0.0"))
755 .unwrap(),
756 Some(tag_path)
757 );
758
759 let mapping = manager.load().unwrap();
760 assert_eq!(mapping.mappings.len(), 2);
761 }
762
763 #[test]
764 fn test_resolve_reference_url_matches_legacy_refs_remotes_and_heads_equivalently() {
765 let temp_dir = TempDir::new().unwrap();
766 let mapping_path = temp_dir.path().join("repos.json");
767 let mut manager = RepoMappingManager {
768 mapping_path: mapping_path.clone(),
769 };
770
771 let repo_path = temp_dir.path().join("repo-legacy");
772 std::fs::create_dir_all(&repo_path).unwrap();
773
774 manager
775 .add_reference_mapping(
776 "https://github.com/org/repo.git".to_string(),
777 Some("refs/remotes/origin/main"),
778 repo_path.clone(),
779 true,
780 )
781 .unwrap();
782
783 let mgr_ro = RepoMappingManager { mapping_path };
784 assert_eq!(
785 mgr_ro
786 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
787 .unwrap(),
788 Some(repo_path)
789 );
790 }
791
792 #[test]
793 fn test_update_reference_sync_time_updates_ref_specific_entry() {
794 let temp_dir = TempDir::new().unwrap();
795 let mapping_path = temp_dir.path().join("repos.json");
796 let mut manager = RepoMappingManager { mapping_path };
797
798 let repo_path = temp_dir.path().join("repo-main");
799 std::fs::create_dir_all(&repo_path).unwrap();
800
801 manager
802 .add_reference_mapping(
803 "https://github.com/org/repo.git".to_string(),
804 Some("refs/heads/main"),
805 repo_path,
806 true,
807 )
808 .unwrap();
809
810 manager
811 .update_reference_sync_time("git@github.com:Org/Repo.git", Some("refs/heads/main"))
812 .unwrap();
813
814 let mapping = manager.load().unwrap();
815 let found = mapping
816 .mappings
817 .iter()
818 .find(|(key, _)| key.contains("#thoughts-ref="))
819 .unwrap();
820 assert!(found.1.last_sync.is_some());
821 }
822
823 #[test]
824 fn test_is_reference_auto_managed_matches_ref_specific_entry() {
825 let temp_dir = TempDir::new().unwrap();
826 let mapping_path = temp_dir.path().join("repos.json");
827 let mut manager = RepoMappingManager {
828 mapping_path: mapping_path.clone(),
829 };
830
831 let repo_path = temp_dir.path().join("repo-main");
832 std::fs::create_dir_all(&repo_path).unwrap();
833
834 manager
835 .add_reference_mapping(
836 "https://github.com/org/repo.git".to_string(),
837 Some("refs/heads/main"),
838 repo_path,
839 true,
840 )
841 .unwrap();
842
843 let mgr_ro = RepoMappingManager { mapping_path };
844 assert!(
845 mgr_ro
846 .is_reference_auto_managed("git@github.com:Org/Repo.git", Some("refs/heads/main"))
847 .unwrap()
848 );
849 }
850
851 #[test]
858 fn test_validate_subpath_accepts_valid_paths() {
859 assert!(validate_subpath("docs").is_ok());
861 assert!(validate_subpath("docs/api").is_ok());
862 assert!(validate_subpath("src/lib/utils").is_ok());
863 assert!(validate_subpath("a/b/c/d/e").is_ok());
864 }
865
866 #[test]
867 fn test_validate_subpath_rejects_parent_dir_traversal() {
868 assert!(validate_subpath("..").is_err());
870 assert!(validate_subpath("../etc").is_err());
871 assert!(validate_subpath("docs/../..").is_err());
872 assert!(validate_subpath("docs/../../etc").is_err());
873 assert!(validate_subpath("a/b/c/../../../etc").is_err());
874 }
875
876 #[test]
877 fn test_validate_subpath_rejects_absolute_paths() {
878 assert!(validate_subpath("/etc").is_err());
880 assert!(validate_subpath("/etc/passwd").is_err());
881 assert!(validate_subpath("/home/user/.ssh").is_err());
882 }
883
884 #[test]
889 fn test_migrate_legacy_when_destination_missing() {
890 let dir = TempDir::new().unwrap();
891 let mapping_path = dir.path().join("repos.json");
892 let legacy_path = dir.path().join("legacy_repos.json");
893
894 let legacy_bytes = br#"{ "mappings": { "git@github.com:a/b.git": { "path": "/tmp/x", "auto_managed": false, "last_sync": null } } }"#;
895 std::fs::write(&legacy_path, legacy_bytes).unwrap();
896
897 let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
898 assert!(migrated);
899 assert!(mapping_path.exists());
900
901 let got = std::fs::read(&mapping_path).unwrap();
902 assert_eq!(got, legacy_bytes);
903 }
904
905 #[test]
906 fn test_migrate_does_not_overwrite_existing_destination() {
907 let dir = TempDir::new().unwrap();
908 let mapping_path = dir.path().join("repos.json");
909 let legacy_path = dir.path().join("legacy_repos.json");
910
911 std::fs::write(&legacy_path, b"legacy").unwrap();
912 std::fs::write(&mapping_path, b"already-there").unwrap();
913
914 let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
915 assert!(!migrated);
916
917 let got = std::fs::read(&mapping_path).unwrap();
918 assert_eq!(got, b"already-there");
919 }
920
921 #[test]
922 fn test_migrate_noop_when_legacy_missing() {
923 let dir = TempDir::new().unwrap();
924 let mapping_path = dir.path().join("repos.json");
925 let legacy_path = dir.path().join("legacy_repos.json"); let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
928 assert!(!migrated);
929 assert!(!mapping_path.exists());
930 }
931
932 #[test]
933 fn test_load_missing_file_returns_default_without_creating_file() {
934 let dir = TempDir::new().unwrap();
935 let mapping_path = dir.path().join("repos.json");
936 let mgr = RepoMappingManager {
937 mapping_path: mapping_path.clone(),
938 };
939
940 let mapping = mgr.load().unwrap();
941 assert_eq!(mapping, RepoMapping::default());
942 assert!(!mapping_path.exists(), "load() must not create repos.json");
943 }
944
945 #[test]
946 fn test_load_reads_existing_file() {
947 let dir = TempDir::new().unwrap();
948 let mapping_path = dir.path().join("repos.json");
949 let mgr = RepoMappingManager {
950 mapping_path: mapping_path.clone(),
951 };
952
953 let mut m = RepoMapping::default();
954 m.mappings.insert(
955 "git@github.com:org/repo.git".to_string(),
956 RepoLocation {
957 path: PathBuf::from("/tmp/repo"),
958 auto_managed: false,
959 last_sync: None,
960 },
961 );
962 mgr.save(&m).unwrap();
963
964 let loaded = mgr.load().unwrap();
965 assert!(loaded.mappings.contains_key("git@github.com:org/repo.git"));
966 }
967}