Skip to main content

thoughts_tool/config/
repo_mapping_manager.rs

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/// Indicates how a URL was resolved to a mapping.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum UrlResolutionKind {
29    /// The URL matched exactly as stored in repos.json
30    Exact,
31    /// The URL matched via canonical identity comparison (different scheme/format)
32    CanonicalFallback,
33}
34
35/// Details about a resolved URL mapping.
36#[derive(Debug, Clone)]
37pub struct ResolvedUrl {
38    /// The key in repos.json that matched
39    pub matched_url: String,
40    /// How the match was found
41    pub resolution: UrlResolutionKind,
42    /// The location details (cloned)
43    pub location: RepoLocation,
44}
45
46pub struct RepoMappingManager {
47    mapping_path: PathBuf,
48}
49
50/// Compute the lock path for a given mapping file path.
51///
52/// This is extracted as a standalone function so it can be used both in
53/// `RepoMappingManager::lock_path()` and `migrate_legacy_repos_json_if_needed()`.
54fn 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
62/// Migrate legacy repos.json to the new location, if needed.
63///
64/// Returns `Ok(true)` if migration was performed, `Ok(false)` otherwise.
65///
66/// # Safety Properties
67///
68/// - Acquires the same lock used by all RMW operations.
69/// - Re-checks `mapping_path.exists()` under lock to close the TOCTOU window.
70/// - Writes via `AtomicFile` for atomic visibility.
71fn 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    // Re-check under lock to close the TOCTOU window.
75    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        // Check for legacy location migration (fast-path guard), then migrate under lock.
108        if !mapping_path.exists()
109            && let Ok(legacy_path) = paths::get_legacy_repo_mapping_path()
110            && legacy_path.exists()
111        {
112            // Performs the lock + recheck + atomic write.
113            let _ = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path)?;
114        }
115
116        Ok(Self { mapping_path })
117    }
118
119    /// Get the lock file path for repos.json RMW operations.
120    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                // Pure read: do not create the file here.
129                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        // Ensure directory exists
141        if let Some(parent) = self.mapping_path.parent() {
142            paths::ensure_dir(parent)?;
143        }
144
145        // Atomic write for safety
146        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    /// Load the mapping while holding an exclusive lock.
154    ///
155    /// Returns the mapping and the lock guard. The lock is released when the
156    /// guard is dropped, so callers should hold it until after `save()`.
157    ///
158    /// Use this for read-modify-write operations to prevent concurrent updates
159    /// from losing changes.
160    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    /// Resolve a git URL with detailed resolution information.
167    ///
168    /// Returns the matched URL key, resolution kind, location, and optional subpath.
169    pub fn resolve_url_with_details(
170        &self,
171        url: &str,
172    ) -> Result<Option<(ResolvedUrl, Option<String>)>> {
173        let mapping = self.load()?; // read-only; atomic writes make this safe
174        let (base_url, subpath) = parse_url_and_subpath(url);
175
176        // Try exact match first
177        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        // Canonical fallback: parse target URL and find a matching key
189        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        // Sort for deterministic selection
205        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    /// Resolve a git URL to its local path.
222    ///
223    /// Uses exact match first, then falls back to canonical identity matching
224    /// to handle URL scheme variants (SSH vs HTTPS).
225    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    /// Add a URL-to-path mapping with identity-based upsert.
262    ///
263    /// If a mapping with the same canonical identity already exists,
264    /// it will be replaced (preserving any existing `last_sync` time).
265    /// This prevents duplicate entries for SSH vs HTTPS variants.
266    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()?; // safe under lock for RMW
269
270        // Basic validation
271        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        // Find all existing entries with the same canonical identity
283        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        // Preserve last_sync from any existing entry
294        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        // Remove all matching entries
300        for k in matching_urls {
301            mapping.mappings.remove(&k);
302        }
303
304        // Insert the new mapping
305        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    /// Remove a URL mapping
362    // TODO(2): Add "thoughts mount unmap" command for cleanup
363    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    /// Check if a URL is auto-managed
372    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    /// Get default clone path for a URL using hierarchical layout.
390    ///
391    /// Returns `~/.thoughts/clones/{host}/{org_path}/{repo}` with sanitized directory names.
392    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(); // use canonical for stable paths across case/scheme
399
400        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    /// Update last sync time for a URL.
428    ///
429    /// Uses canonical fallback to update the correct entry even if the URL
430    /// scheme differs from what's stored.
431    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        // Prefer exact base_url key
437        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        // Fall back to canonical match
445        // Note: We need to find the matching key without holding a mutable borrow
446        let target_key = RepoIdentity::parse(&base_url)?.canonical_key();
447
448        // TODO(2): If repos.json contains multiple entries with the same canonical identity (legacy
449        // duplicates), the selection below is nondeterministic due to HashMap iteration order.
450        // Consider sorting (as in `resolve_url_with_details`) or updating all matches.
451        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    /// Get the canonical identity key for a URL, if parseable.
484    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
532/// Parse a URL into (`base_url`, `optional_subpath`).
533///
534/// Delegates to the `repo_identity` module for robust port-aware parsing.
535pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
536    identity_parse_url_and_subpath(url)
537}
538
539/// Validate a subpath to prevent directory traversal attacks.
540///
541/// Rejects absolute paths and paths containing ".." components that could
542/// escape the repository root directory.
543fn 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    // Handle different URL formats
563    if let Some(pos) = url.rfind('/') {
564        Ok(url[pos + 1..].to_string())
565    } else if let Some(pos) = url.rfind(':') {
566        // SSH format like git@github.com:user/repo
567        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
577/// Extract `org_path` and repo from a URL.
578///
579/// Delegates to `RepoIdentity` for robust parsing that handles:
580/// - SSH with ports: `ssh://git@host:2222/org/repo.git`
581/// - GitLab subgroups: `https://gitlab.com/a/b/c/repo.git`
582/// - Azure DevOps: `https://dev.azure.com/org/proj/_git/repo`
583pub 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        // Test hierarchical path: ~/.thoughts/clones/{host}/{org}/{repo}
649        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    // TODO(2): Add integration test for resolve_url_with_details canonical fallback path.
833    // This test verifies keys match, but doesn't test that resolve_url_with_details
834    // actually uses canonical matching to find mappings stored under different URL schemes.
835    // Test should: 1) add mapping with SSH URL, 2) resolve with HTTPS URL,
836    // 3) verify CanonicalFallback resolution kind is returned.
837
838    #[test]
839    fn test_validate_subpath_accepts_valid_paths() {
840        // Simple relative paths should be accepted
841        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        // Parent directory traversal should be rejected
850        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        // Absolute paths should be rejected
860        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    // -------------------------------------------------------------------------
866    // Migration and load() TOCTOU-fix tests
867    // -------------------------------------------------------------------------
868
869    #[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"); // intentionally absent
907
908        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}