Skip to main content

thoughts_tool/config/
repo_manager.rs

1use crate::config::{
2    ContextMount, Mount, MountDirs, MountDirsV2, ReferenceEntry, ReferenceMount, RepoConfig,
3    RepoConfigV2, RequiredMount, SyncStrategy, ThoughtsMount,
4};
5use crate::mount::MountSpace;
6use crate::utils::paths;
7use anyhow::{Context, Result};
8use atomicwrites::{AtomicFile, OverwriteBehavior};
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
14pub struct DesiredState {
15    pub mount_dirs: MountDirsV2,
16    pub thoughts_mount: Option<ThoughtsMount>,
17    pub context_mounts: Vec<ContextMount>,
18    pub references: Vec<ReferenceMount>,
19    pub was_v1: bool, // for messaging
20}
21
22impl DesiredState {
23    /// Find a mount by its MountSpace identifier
24    pub fn find_mount(&self, space: &MountSpace) -> Option<Mount> {
25        match space {
26            MountSpace::Thoughts => self.thoughts_mount.as_ref().map(|tm| Mount::Git {
27                url: tm.remote.clone(),
28                sync: tm.sync,
29                subpath: tm.subpath.clone(),
30            }),
31            MountSpace::Context(mount_path) => self
32                .context_mounts
33                .iter()
34                .find(|cm| &cm.mount_path == mount_path)
35                .map(|cm| Mount::Git {
36                    url: cm.remote.clone(),
37                    sync: cm.sync,
38                    subpath: cm.subpath.clone(),
39                }),
40            MountSpace::Reference {
41                org_path: _,
42                repo: _,
43                ref_key: _,
44            } => {
45                // References need URL lookup - for now return None
46                // This will be addressed when references commands are implemented
47                None
48            }
49        }
50    }
51
52    /// Get target path for a mount space
53    pub fn get_mount_target(&self, space: &MountSpace, repo_root: &Path) -> PathBuf {
54        repo_root
55            .join(".thoughts-data")
56            .join(space.relative_path(&self.mount_dirs))
57    }
58}
59
60pub struct RepoConfigManager {
61    repo_root: PathBuf,
62}
63
64impl RepoConfigManager {
65    pub fn new(repo_root: PathBuf) -> Self {
66        // Ensure absolute path at construction (defense-in-depth)
67        let abs = if repo_root.is_absolute() {
68            repo_root
69        } else {
70            std::fs::canonicalize(&repo_root).unwrap_or_else(|_| {
71                std::env::current_dir()
72                    .expect("Failed to determine current directory for path normalization")
73                    .join(&repo_root)
74            })
75        };
76        Self { repo_root: abs }
77    }
78
79    /// Load v1 configuration. Prefer using `load_desired_state()` or `ensure_v2_default()` for new code.
80    pub fn load(&self) -> Result<Option<RepoConfig>> {
81        let config_path = paths::get_repo_config_path(&self.repo_root);
82        if !config_path.exists() {
83            return Ok(None);
84        }
85
86        let content = fs::read_to_string(&config_path)
87            .with_context(|| format!("Failed to read config from {config_path:?}"))?;
88        let config: RepoConfig = serde_json::from_str(&content)
89            .with_context(|| "Failed to parse repository configuration")?;
90
91        self.validate(&config)?;
92        Ok(Some(config))
93    }
94
95    /// Save v1 configuration. Prefer using `save_v2_validated()` for new code.
96    pub fn save(&self, config: &RepoConfig) -> Result<()> {
97        self.validate(config)?;
98
99        let config_path = paths::get_repo_config_path(&self.repo_root);
100
101        // Ensure .thoughts directory exists
102        if let Some(parent) = config_path.parent() {
103            fs::create_dir_all(parent)
104                .with_context(|| format!("Failed to create directory {parent:?}"))?;
105        }
106
107        let json =
108            serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
109
110        AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
111            .write(|f| f.write_all(json.as_bytes()))
112            .with_context(|| format!("Failed to write config to {config_path:?}"))?;
113
114        Ok(())
115    }
116
117    /// Ensure v1 default configuration. Prefer using `ensure_v2_default()` for new code.
118    pub fn ensure_default(&self) -> Result<RepoConfig> {
119        if let Some(config) = self.load()? {
120            return Ok(config);
121        }
122
123        let default_config = RepoConfig {
124            version: "1.0".to_string(),
125            mount_dirs: MountDirs::default(),
126            requires: vec![],
127            rules: vec![],
128        };
129
130        self.save(&default_config)?;
131        Ok(default_config)
132    }
133
134    pub fn load_desired_state(&self) -> Result<Option<DesiredState>> {
135        let config_path = paths::get_repo_config_path(&self.repo_root);
136        if !config_path.exists() {
137            return Ok(None);
138        }
139
140        let raw = std::fs::read_to_string(&config_path)?;
141        // Peek version
142        let v: serde_json::Value = serde_json::from_str(&raw)?;
143        let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
144
145        if version == "2.0" {
146            let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
147            // Normalize ReferenceEntry to ReferenceMount
148            let refs = v2
149                .references
150                .into_iter()
151                .map(|e| match e {
152                    ReferenceEntry::Simple(url) => ReferenceMount {
153                        remote: url,
154                        description: None,
155                        ref_name: None,
156                    },
157                    ReferenceEntry::WithMetadata(rm) => rm,
158                })
159                .collect();
160            return Ok(Some(DesiredState {
161                mount_dirs: v2.mount_dirs,
162                thoughts_mount: v2.thoughts_mount,
163                context_mounts: v2.context_mounts,
164                references: refs,
165                was_v1: false,
166            }));
167        }
168
169        // Fallback: v1 mapping (legacy RepoConfig)
170        let v1: RepoConfig = serde_json::from_str(&raw)?;
171        let ds = self.map_v1_to_desired_state(&v1);
172        Ok(Some(ds))
173    }
174
175    fn validate(&self, config: &RepoConfig) -> Result<()> {
176        // Validate version
177        if config.version != "1.0" {
178            anyhow::bail!("Unsupported configuration version: {}", config.version);
179        }
180
181        // Validate mount directories don't conflict
182        if config.mount_dirs.repository == "personal" {
183            anyhow::bail!("Repository mount directory cannot be named 'personal'");
184        }
185
186        if config.mount_dirs.repository == config.mount_dirs.personal {
187            anyhow::bail!("Repository and personal mount directories must be different");
188        }
189
190        // Validate mount paths are unique
191        let mut seen_paths = std::collections::HashSet::new();
192        for mount in &config.requires {
193            if !seen_paths.insert(&mount.mount_path) {
194                anyhow::bail!("Duplicate mount path: {}", mount.mount_path);
195            }
196        }
197
198        // Validate required mounts have valid remotes
199        for mount in &config.requires {
200            self.validate_remote(&mount.remote)?;
201        }
202
203        // Validate rules have valid patterns
204        for rule in &config.rules {
205            glob::Pattern::new(&rule.pattern)
206                .with_context(|| format!("Invalid pattern: {}", rule.pattern))?;
207        }
208
209        Ok(())
210    }
211
212    fn validate_remote(&self, remote: &str) -> Result<()> {
213        if remote.starts_with("./") {
214            // Local mount - relative path is OK
215            return Ok(());
216        }
217
218        if !remote.starts_with("git@")
219            && !remote.starts_with("https://")
220            && !remote.starts_with("ssh://")
221        {
222            anyhow::bail!(
223                "Invalid remote URL: {}. Must be a git URL or relative path starting with ./",
224                remote
225            );
226        }
227
228        Ok(())
229    }
230
231    #[allow(dead_code)]
232    // TODO(2): Refactor mount add/remove to use these manager methods
233    pub fn add_mount(&mut self, mount: RequiredMount) -> Result<()> {
234        let mut config = self.load()?.unwrap_or_else(|| RepoConfig {
235            version: "1.0".to_string(),
236            mount_dirs: MountDirs::default(),
237            requires: vec![],
238            rules: vec![],
239        });
240
241        // Check for duplicate mount paths
242        if config
243            .requires
244            .iter()
245            .any(|m| m.mount_path == mount.mount_path)
246        {
247            anyhow::bail!("Mount path '{}' already exists", mount.mount_path);
248        }
249
250        config.requires.push(mount);
251        self.save(&config)?;
252        Ok(())
253    }
254
255    #[allow(dead_code)]
256    // TODO(2): Refactor mount remove to use this method
257    pub fn remove_mount(&mut self, mount_path: &str) -> Result<bool> {
258        let mut config = self
259            .load()?
260            .ok_or_else(|| anyhow::anyhow!("No repository configuration found"))?;
261
262        let initial_len = config.requires.len();
263        config.requires.retain(|m| m.mount_path != mount_path);
264
265        if config.requires.len() == initial_len {
266            return Ok(false);
267        }
268
269        self.save(&config)?;
270        Ok(true)
271    }
272
273    /// Load v2 config or error if it doesn't exist
274    fn map_v1_to_desired_state(&self, v1: &RepoConfig) -> DesiredState {
275        // Map to v2-like DesiredState
276        let defaults = MountDirsV2::default();
277        let mut context_mounts = vec![];
278        let mut references = vec![];
279
280        for req in &v1.requires {
281            let is_ref =
282                req.mount_path.starts_with("references/") || req.sync == SyncStrategy::None;
283            if is_ref {
284                references.push(ReferenceMount {
285                    remote: req.remote.clone(),
286                    description: Some(req.description.clone()),
287                    ref_name: None,
288                });
289            } else {
290                context_mounts.push(ContextMount {
291                    remote: req.remote.clone(),
292                    subpath: req.subpath.clone(),
293                    mount_path: req.mount_path.clone(),
294                    // guardrail; never none for context
295                    sync: if req.sync == SyncStrategy::None {
296                        SyncStrategy::Auto
297                    } else {
298                        req.sync
299                    },
300                });
301            }
302        }
303
304        DesiredState {
305            mount_dirs: MountDirsV2 {
306                thoughts: defaults.thoughts,
307                context: v1.mount_dirs.repository.clone(), // keep existing "context" name
308                references: defaults.references,
309            },
310            thoughts_mount: None, // requires explicit config in v2
311            context_mounts,
312            references,
313            was_v1: true,
314        }
315    }
316
317    /// Load v2 config or error if it doesn't exist
318    pub fn load_v2_or_bail(&self) -> Result<RepoConfigV2> {
319        let config_path = paths::get_repo_config_path(&self.repo_root);
320        if !config_path.exists() {
321            anyhow::bail!("No repository configuration found. Run 'thoughts init' first.");
322        }
323
324        let raw = std::fs::read_to_string(&config_path)?;
325        let v: serde_json::Value = serde_json::from_str(&raw)?;
326        let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
327
328        if version == "2.0" {
329            let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
330            Ok(v2)
331        } else {
332            anyhow::bail!(
333                "Repository is using v1 configuration. Please migrate to v2 configuration format."
334            );
335        }
336    }
337
338    /// Save v2 configuration
339    pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
340        let config_path = paths::get_repo_config_path(&self.repo_root);
341
342        // Ensure .thoughts directory exists
343        if let Some(parent) = config_path.parent() {
344            fs::create_dir_all(parent)
345                .with_context(|| format!("Failed to create directory {parent:?}"))?;
346        }
347
348        let json =
349            serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
350
351        AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
352            .write(|f| f.write_all(json.as_bytes()))
353            .with_context(|| format!("Failed to write config to {config_path:?}"))?;
354
355        Ok(())
356    }
357
358    /// Ensure v2 config exists, create default if not
359    pub fn ensure_v2_default(&self) -> Result<RepoConfigV2> {
360        let config_path = paths::get_repo_config_path(&self.repo_root);
361        if config_path.exists() {
362            // Try to load existing config
363            let raw = std::fs::read_to_string(&config_path)?;
364            let v: serde_json::Value = serde_json::from_str(&raw)?;
365            let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
366
367            if version == "2.0" {
368                return serde_json::from_str(&raw).context("Failed to parse v2 configuration");
369            }
370
371            // v1 path: parse full v1 once, then map without re-reading
372            let v1: RepoConfig = serde_json::from_str(&raw)?;
373            let ds = self.map_v1_to_desired_state(&v1);
374
375            // Backup only if meaningful content present
376            let needs_backup =
377                !ds.context_mounts.is_empty() || !ds.references.is_empty() || !v1.rules.is_empty();
378            if needs_backup {
379                use chrono::Local;
380                let ts = Local::now().format("%Y%m%d-%H%M%S");
381                let backup_path = config_path
382                    .parent()
383                    .unwrap()
384                    .join(format!("config.v1.bak-{}.json", ts));
385                AtomicFile::new(&backup_path, OverwriteBehavior::AllowOverwrite)
386                    .write(|f| f.write_all(raw.as_bytes()))
387                    .with_context(|| format!("Failed to write backup to {:?}", backup_path))?;
388            }
389
390            // Build v2 config from DesiredState
391            let v2_config = RepoConfigV2 {
392                version: "2.0".to_string(),
393                mount_dirs: ds.mount_dirs,
394                thoughts_mount: ds.thoughts_mount,
395                context_mounts: ds.context_mounts,
396                references: ds
397                    .references
398                    .into_iter()
399                    .map(|rm| {
400                        if rm.description.is_some() || rm.ref_name.is_some() {
401                            ReferenceEntry::WithMetadata(rm)
402                        } else {
403                            ReferenceEntry::Simple(rm.remote)
404                        }
405                    })
406                    .collect(),
407            };
408
409            // Save with hard validation
410            self.save_v2_validated(&v2_config)?;
411            return Ok(v2_config);
412        }
413
414        // Create default v2 config
415        let default_config = RepoConfigV2 {
416            version: "2.0".to_string(),
417            mount_dirs: MountDirsV2::default(),
418            thoughts_mount: None,
419            context_mounts: vec![],
420            references: vec![],
421        };
422
423        self.save_v2(&default_config)?;
424        Ok(default_config)
425    }
426
427    /// Soft validation for v2 configuration returning warnings only
428    pub fn validate_v2_soft(&self, cfg: &RepoConfigV2) -> Vec<String> {
429        let mut warnings = Vec::new();
430        for r in &cfg.references {
431            let url = match r {
432                ReferenceEntry::Simple(s) => s.as_str(),
433                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
434            };
435            if let Err(e) = crate::config::validation::validate_reference_url(url) {
436                warnings.push(format!("Invalid reference '{}': {}", url, e));
437            }
438        }
439        warnings
440    }
441
442    /// Peek the on-disk config version without fully parsing
443    pub fn peek_config_version(&self) -> Result<Option<String>> {
444        let config_path = paths::get_repo_config_path(&self.repo_root);
445        if !config_path.exists() {
446            return Ok(None);
447        }
448        let raw = std::fs::read_to_string(&config_path)?;
449        let v: serde_json::Value = serde_json::from_str(&raw)?;
450        Ok(v.get("version")
451            .and_then(|x| x.as_str())
452            .map(|s| s.to_string()))
453    }
454
455    /// Hard validator for v2 config. Returns warnings (non-fatal).
456    pub fn validate_v2_hard(&self, cfg: &RepoConfigV2) -> Result<Vec<String>> {
457        if cfg.version != "2.0" {
458            anyhow::bail!("Unsupported configuration version: {}", cfg.version);
459        }
460
461        // mount_dirs: non-empty and distinct
462        let m = &cfg.mount_dirs;
463        for (name, val) in [
464            ("thoughts", &m.thoughts),
465            ("context", &m.context),
466            ("references", &m.references),
467        ] {
468            if val.trim().is_empty() {
469                anyhow::bail!("Mount directory '{}' cannot be empty", name);
470            }
471            if val == ".thoughts-data" {
472                anyhow::bail!(
473                    "Mount directory '{}' cannot be named '.thoughts-data'",
474                    name
475                );
476            }
477            if val == "." || val == ".." {
478                anyhow::bail!("Mount directory '{}' cannot be '.' or '..'", name);
479            }
480            if val.contains('/') || val.contains('\\') {
481                anyhow::bail!(
482                    "Mount directory '{}' must be a single path segment (got {})",
483                    name,
484                    val
485                );
486            }
487        }
488        if m.thoughts == m.context || m.thoughts == m.references || m.context == m.references {
489            anyhow::bail!("Mount directories must be distinct (thoughts/context/references)");
490        }
491
492        // thoughts_mount remote validation
493        if let Some(tm) = &cfg.thoughts_mount {
494            self.validate_remote(&tm.remote)?;
495        }
496
497        // context_mounts: unique mount_path, valid remotes; warn on sync:None
498        let mut warnings = Vec::new();
499        let mut seen_mount_paths = std::collections::HashSet::new();
500        for cm in &cfg.context_mounts {
501            // uniqueness
502            if !seen_mount_paths.insert(&cm.mount_path) {
503                anyhow::bail!("Duplicate context mount path: {}", cm.mount_path);
504            }
505
506            // mount_path validation
507            let mp = cm.mount_path.trim();
508            if mp.is_empty() {
509                anyhow::bail!("Context mount path cannot be empty");
510            }
511            if mp == "." || mp == ".." {
512                anyhow::bail!("Context mount path cannot be '.' or '..'");
513            }
514            if mp.contains('/') || mp.contains('\\') {
515                anyhow::bail!(
516                    "Context mount path must be a single path segment (got {})",
517                    cm.mount_path
518                );
519            }
520            let m = &cfg.mount_dirs;
521            if mp == m.thoughts || mp == m.context || mp == m.references {
522                anyhow::bail!(
523                    "Context mount path '{}' cannot conflict with configured mount_dirs names ('{}', '{}', '{}')",
524                    cm.mount_path,
525                    m.thoughts,
526                    m.context,
527                    m.references
528                );
529            }
530
531            // remote validity
532            self.validate_remote(&cm.remote)?;
533            if matches!(cm.sync, SyncStrategy::None) {
534                warnings.push(format!(
535                    "Context mount '{}' has sync:None; allowed but discouraged. Consider SyncStrategy::Auto.",
536                    cm.mount_path
537                ));
538            }
539        }
540
541        // references: validate and ensure uniqueness by canonical key
542        use crate::config::validation::{
543            canonical_reference_instance_key, validate_pinned_ref_full_name, validate_reference_url,
544        };
545        let mut seen_refs = std::collections::HashSet::new();
546        for r in &cfg.references {
547            let (url, ref_name) = match r {
548                ReferenceEntry::Simple(s) => (s.as_str(), None),
549                ReferenceEntry::WithMetadata(rm) => (rm.remote.as_str(), rm.ref_name.as_deref()),
550            };
551            validate_reference_url(url).with_context(|| format!("Invalid reference '{}'", url))?;
552            if let Some(ref_name) = ref_name {
553                if ref_name.starts_with("refs/remotes/") {
554                    warnings.push(format!(
555                        "Reference '{}' uses legacy pinned ref '{}'. New references should use refs/heads/* or refs/tags/*; refs/remotes/* is a local remote-tracking namespace.",
556                        url, ref_name
557                    ));
558                }
559                validate_pinned_ref_full_name(ref_name).with_context(|| {
560                    format!("Invalid pinned ref '{}' for reference '{}'", ref_name, url)
561                })?;
562            }
563            let key = canonical_reference_instance_key(url, ref_name)?;
564            if !seen_refs.insert(key) {
565                anyhow::bail!("Duplicate reference detected: {}", url);
566            }
567        }
568
569        Ok(warnings)
570    }
571
572    /// Save v2 configuration with hard validation. Returns warnings (non-fatal).
573    pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
574        let warnings = self.validate_v2_hard(config)?;
575        self.save_v2(config)?;
576        Ok(warnings)
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use tempfile::TempDir;
584
585    #[test]
586    fn test_save_and_load_config() {
587        let temp_dir = TempDir::new().unwrap();
588        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
589
590        let config = RepoConfig {
591            version: "1.0".to_string(),
592            mount_dirs: MountDirs::default(),
593            requires: vec![RequiredMount {
594                remote: "git@github.com:test/repo.git".to_string(),
595                mount_path: "test".to_string(),
596                subpath: None,
597                description: "Test repository".to_string(),
598                optional: false,
599                override_rules: None,
600                sync: crate::config::SyncStrategy::Auto,
601            }],
602            rules: vec![],
603        };
604
605        // Save
606        manager.save(&config).unwrap();
607
608        // Load
609        let loaded = manager.load().unwrap().unwrap();
610
611        assert_eq!(loaded.version, config.version);
612        assert_eq!(loaded.requires.len(), config.requires.len());
613        assert_eq!(loaded.requires[0].remote, config.requires[0].remote);
614    }
615
616    #[test]
617    fn test_validation_invalid_version() {
618        let temp_dir = TempDir::new().unwrap();
619        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
620
621        let config = RepoConfig {
622            version: "2.0".to_string(), // Invalid version
623            mount_dirs: MountDirs::default(),
624            requires: vec![],
625            rules: vec![],
626        };
627
628        assert!(manager.save(&config).is_err());
629    }
630
631    #[test]
632    fn test_validation_conflicting_mount_dirs() {
633        let temp_dir = TempDir::new().unwrap();
634        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
635
636        let config = RepoConfig {
637            version: "1.0".to_string(),
638            mount_dirs: MountDirs {
639                repository: "personal".to_string(), // Invalid: can't be named "personal"
640                personal: "personal".to_string(),
641            },
642            requires: vec![],
643            rules: vec![],
644        };
645
646        assert!(manager.save(&config).is_err());
647    }
648
649    #[test]
650    fn test_validation_duplicate_mount_paths() {
651        let temp_dir = TempDir::new().unwrap();
652        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
653
654        let config = RepoConfig {
655            version: "1.0".to_string(),
656            mount_dirs: MountDirs::default(),
657            requires: vec![
658                RequiredMount {
659                    remote: "git@github.com:test/repo1.git".to_string(),
660                    mount_path: "test".to_string(),
661                    subpath: None,
662                    description: "Test 1".to_string(),
663                    optional: false,
664                    override_rules: None,
665                    sync: crate::config::SyncStrategy::None,
666                },
667                RequiredMount {
668                    remote: "git@github.com:test/repo2.git".to_string(),
669                    mount_path: "test".to_string(), // Duplicate
670                    subpath: None,
671                    description: "Test 2".to_string(),
672                    optional: false,
673                    override_rules: None,
674                    sync: crate::config::SyncStrategy::None,
675                },
676            ],
677            rules: vec![],
678        };
679
680        assert!(manager.save(&config).is_err());
681    }
682
683    #[test]
684    fn test_validation_invalid_remote() {
685        let temp_dir = TempDir::new().unwrap();
686        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
687
688        let config = RepoConfig {
689            version: "1.0".to_string(),
690            mount_dirs: MountDirs::default(),
691            requires: vec![RequiredMount {
692                remote: "invalid-url".to_string(), // Invalid URL
693                mount_path: "test".to_string(),
694                subpath: None,
695                description: "Test".to_string(),
696                optional: false,
697                override_rules: None,
698                sync: crate::config::SyncStrategy::None,
699            }],
700            rules: vec![],
701        };
702
703        assert!(manager.save(&config).is_err());
704    }
705
706    #[test]
707    fn test_validation_local_mount() {
708        let temp_dir = TempDir::new().unwrap();
709        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
710
711        let config = RepoConfig {
712            version: "1.0".to_string(),
713            mount_dirs: MountDirs::default(),
714            requires: vec![RequiredMount {
715                remote: "./local/path".to_string(), // Valid local mount
716                mount_path: "local".to_string(),
717                subpath: None,
718                description: "Local mount".to_string(),
719                optional: false,
720                override_rules: None,
721                sync: crate::config::SyncStrategy::None,
722            }],
723            rules: vec![],
724        };
725
726        assert!(manager.save(&config).is_ok());
727    }
728
729    #[test]
730    fn test_add_and_remove_mount() {
731        let temp_dir = TempDir::new().unwrap();
732        let mut manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
733
734        // Add mount
735        let mount = RequiredMount {
736            remote: "git@github.com:test/repo.git".to_string(),
737            mount_path: "test".to_string(),
738            subpath: None,
739            description: "Test repository".to_string(),
740            optional: false,
741            override_rules: None,
742            sync: crate::config::SyncStrategy::Auto,
743        };
744
745        manager.add_mount(mount.clone()).unwrap();
746
747        // Verify it was added
748        let config = manager.load().unwrap().unwrap();
749        assert_eq!(config.requires.len(), 1);
750        assert_eq!(config.requires[0].mount_path, "test");
751
752        // Remove mount
753        assert!(manager.remove_mount("test").unwrap());
754
755        // Verify it was removed
756        let config = manager.load().unwrap().unwrap();
757        assert_eq!(config.requires.len(), 0);
758
759        // Try to remove non-existent mount
760        assert!(!manager.remove_mount("test").unwrap());
761    }
762
763    #[test]
764    fn test_v1_to_desired_state_mapping() {
765        let temp_dir = TempDir::new().unwrap();
766        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
767
768        // Create a v1 config with both context mounts and references
769        let v1_config = RepoConfig {
770            version: "1.0".to_string(),
771            mount_dirs: MountDirs {
772                repository: "context".to_string(),
773                personal: "personal".to_string(),
774            },
775            requires: vec![
776                RequiredMount {
777                    remote: "git@github.com:user/context-repo.git".to_string(),
778                    mount_path: "context-mount".to_string(),
779                    subpath: Some("subdir".to_string()),
780                    description: "Context mount".to_string(),
781                    optional: false,
782                    override_rules: None,
783                    sync: crate::config::SyncStrategy::Auto,
784                },
785                RequiredMount {
786                    remote: "git@github.com:org/ref-repo.git".to_string(),
787                    mount_path: "references/ref-mount".to_string(),
788                    subpath: None,
789                    description: "Reference mount".to_string(),
790                    optional: true,
791                    override_rules: None,
792                    sync: crate::config::SyncStrategy::None,
793                },
794            ],
795            rules: vec![],
796        };
797
798        // Save the v1 config
799        manager.save(&v1_config).unwrap();
800
801        // Load as DesiredState
802        let desired_state = manager.load_desired_state().unwrap().unwrap();
803
804        // Verify the mapping
805        assert!(desired_state.was_v1);
806        assert_eq!(desired_state.mount_dirs.context, "context");
807        assert_eq!(desired_state.mount_dirs.thoughts, "thoughts");
808        assert_eq!(desired_state.mount_dirs.references, "references");
809
810        // Check context mounts
811        assert_eq!(desired_state.context_mounts.len(), 1);
812        assert_eq!(
813            desired_state.context_mounts[0].remote,
814            "git@github.com:user/context-repo.git"
815        );
816        assert_eq!(desired_state.context_mounts[0].mount_path, "context-mount");
817        assert_eq!(
818            desired_state.context_mounts[0].subpath,
819            Some("subdir".to_string())
820        );
821
822        // Check references
823        assert_eq!(desired_state.references.len(), 1);
824        assert_eq!(
825            desired_state.references[0].remote,
826            "git@github.com:org/ref-repo.git"
827        );
828        assert_eq!(
829            desired_state.references[0].description.as_deref(),
830            Some("Reference mount")
831        );
832
833        // Verify no thoughts mount (requires explicit config in v2)
834        assert!(desired_state.thoughts_mount.is_none());
835    }
836
837    #[test]
838    fn test_v2_config_loading() {
839        let temp_dir = TempDir::new().unwrap();
840        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
841
842        // Create a v2 config
843        let v2_config = crate::config::RepoConfigV2 {
844            version: "2.0".to_string(),
845            mount_dirs: crate::config::MountDirsV2::default(),
846            thoughts_mount: Some(crate::config::ThoughtsMount {
847                remote: "git@github.com:user/thoughts.git".to_string(),
848                subpath: None,
849                sync: crate::config::SyncStrategy::Auto,
850            }),
851            context_mounts: vec![crate::config::ContextMount {
852                remote: "git@github.com:user/context.git".to_string(),
853                subpath: Some("docs".to_string()),
854                mount_path: "docs".to_string(),
855                sync: crate::config::SyncStrategy::Auto,
856            }],
857            references: vec![
858                ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
859                ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
860            ],
861        };
862
863        // Save the v2 config directly using JSON
864        let config_path = paths::get_repo_config_path(temp_dir.path());
865        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
866        let json = serde_json::to_string_pretty(&v2_config).unwrap();
867        std::fs::write(&config_path, json).unwrap();
868
869        // Load as DesiredState
870        let desired_state = manager.load_desired_state().unwrap().unwrap();
871
872        // Verify the loading
873        assert!(!desired_state.was_v1);
874        assert!(desired_state.thoughts_mount.is_some());
875        assert_eq!(
876            desired_state.thoughts_mount.as_ref().unwrap().remote,
877            "git@github.com:user/thoughts.git"
878        );
879        assert_eq!(desired_state.context_mounts.len(), 1);
880        assert_eq!(desired_state.references.len(), 2);
881    }
882
883    #[test]
884    fn test_v2_references_normalize_to_reference_mount() {
885        let temp_dir = TempDir::new().unwrap();
886        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
887
888        let json = r#"{
889            "version": "2.0",
890            "mount_dirs": {},
891            "context_mounts": [],
892            "references": [
893                "git@github.com:org/ref1.git",
894                {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
895            ]
896        }"#;
897
898        let config_path = paths::get_repo_config_path(temp_dir.path());
899        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
900        std::fs::write(&config_path, json).unwrap();
901
902        let ds = manager.load_desired_state().unwrap().unwrap();
903        assert_eq!(ds.references.len(), 2);
904        assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
905        assert_eq!(ds.references[0].description, None);
906        assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
907        assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
908    }
909
910    #[test]
911    fn test_v1_migration_preserves_reference_descriptions() {
912        let temp_dir = TempDir::new().unwrap();
913        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
914
915        // Create v1 config with reference having description
916        let v1_config = RepoConfig {
917            version: "1.0".to_string(),
918            mount_dirs: MountDirs::default(),
919            requires: vec![RequiredMount {
920                remote: "git@github.com:org/ref-repo.git".to_string(),
921                mount_path: "references/ref-mount".to_string(),
922                subpath: None,
923                description: "Important reference repository".to_string(),
924                optional: true,
925                override_rules: None,
926                sync: crate::config::SyncStrategy::None,
927            }],
928            rules: vec![],
929        };
930
931        // Save the v1 config
932        manager.save(&v1_config).unwrap();
933
934        // Load via load_desired_state()
935        let ds = manager.load_desired_state().unwrap().unwrap();
936
937        // Verify description is preserved in DesiredState.references
938        assert!(ds.was_v1);
939        assert_eq!(ds.references.len(), 1);
940        assert_eq!(ds.references[0].remote, "git@github.com:org/ref-repo.git");
941        assert_eq!(
942            ds.references[0].description.as_deref(),
943            Some("Important reference repository")
944        );
945    }
946
947    #[test]
948    fn test_validate_v2_soft_handles_both_variants() {
949        let temp_dir = tempfile::TempDir::new().unwrap();
950        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
951
952        let cfg = RepoConfigV2 {
953            version: "2.0".into(),
954            mount_dirs: MountDirsV2::default(),
955            thoughts_mount: None,
956            context_mounts: vec![],
957            references: vec![
958                ReferenceEntry::Simple("https://github.com/org/repo".into()),
959                ReferenceEntry::WithMetadata(ReferenceMount {
960                    remote: "git@github.com:org/repo.git:docs".into(), // invalid: subpath
961                    description: None,
962                    ref_name: None,
963                }),
964            ],
965        };
966
967        let warnings = mgr.validate_v2_soft(&cfg);
968        assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
969        assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
970    }
971
972    #[test]
973    fn test_peek_config_version_returns_none_when_no_config() {
974        let temp_dir = tempfile::TempDir::new().unwrap();
975        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
976        assert_eq!(mgr.peek_config_version().unwrap(), None);
977    }
978
979    #[test]
980    fn test_peek_config_version_returns_v1() {
981        let temp_dir = tempfile::TempDir::new().unwrap();
982        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
983        let v1_config = RepoConfig {
984            version: "1.0".to_string(),
985            mount_dirs: MountDirs::default(),
986            requires: vec![],
987            rules: vec![],
988        };
989        mgr.save(&v1_config).unwrap();
990        assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
991    }
992
993    #[test]
994    fn test_peek_config_version_returns_v2() {
995        let temp_dir = tempfile::TempDir::new().unwrap();
996        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
997        let v2_config = RepoConfigV2 {
998            version: "2.0".to_string(),
999            mount_dirs: MountDirsV2::default(),
1000            thoughts_mount: None,
1001            context_mounts: vec![],
1002            references: vec![],
1003        };
1004        mgr.save_v2(&v2_config).unwrap();
1005        assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
1006    }
1007
1008    #[test]
1009    fn test_validate_v2_hard_rejects_invalid_version() {
1010        let temp_dir = tempfile::TempDir::new().unwrap();
1011        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1012        let cfg = RepoConfigV2 {
1013            version: "3.0".to_string(),
1014            mount_dirs: MountDirsV2::default(),
1015            thoughts_mount: None,
1016            context_mounts: vec![],
1017            references: vec![],
1018        };
1019        let result = mgr.validate_v2_hard(&cfg);
1020        assert!(result.is_err());
1021        assert!(
1022            result
1023                .unwrap_err()
1024                .to_string()
1025                .contains("Unsupported configuration version: 3.0")
1026        );
1027    }
1028
1029    #[test]
1030    fn test_validate_v2_hard_rejects_pinned_ref_with_whitespace_or_trailing_slash() {
1031        let temp_dir = tempfile::TempDir::new().unwrap();
1032        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1033
1034        for bad_ref in [" refs/heads/main", "refs/heads/main ", "refs/heads/main/"] {
1035            let cfg = RepoConfigV2 {
1036                version: "2.0".to_string(),
1037                mount_dirs: MountDirsV2::default(),
1038                thoughts_mount: None,
1039                context_mounts: vec![],
1040                references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1041                    remote: "https://github.com/org/repo".to_string(),
1042                    description: None,
1043                    ref_name: Some(bad_ref.to_string()),
1044                })],
1045            };
1046
1047            assert!(
1048                mgr.validate_v2_hard(&cfg).is_err(),
1049                "expected {bad_ref:?} to fail hard validation"
1050            );
1051        }
1052    }
1053
1054    #[test]
1055    fn test_validate_v2_hard_rejects_empty_mount_dirs() {
1056        let temp_dir = tempfile::TempDir::new().unwrap();
1057        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1058        let cfg = RepoConfigV2 {
1059            version: "2.0".to_string(),
1060            mount_dirs: MountDirsV2 {
1061                thoughts: "".to_string(),
1062                context: "context".to_string(),
1063                references: "references".to_string(),
1064            },
1065            thoughts_mount: None,
1066            context_mounts: vec![],
1067            references: vec![],
1068        };
1069        let result = mgr.validate_v2_hard(&cfg);
1070        assert!(result.is_err());
1071        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1072    }
1073
1074    #[test]
1075    fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
1076        let temp_dir = tempfile::TempDir::new().unwrap();
1077        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1078        let cfg = RepoConfigV2 {
1079            version: "2.0".to_string(),
1080            mount_dirs: MountDirsV2 {
1081                thoughts: ".thoughts-data".to_string(),
1082                context: "context".to_string(),
1083                references: "references".to_string(),
1084            },
1085            thoughts_mount: None,
1086            context_mounts: vec![],
1087            references: vec![],
1088        };
1089        let result = mgr.validate_v2_hard(&cfg);
1090        assert!(result.is_err());
1091        assert!(result.unwrap_err().to_string().contains(".thoughts-data"));
1092    }
1093
1094    #[test]
1095    fn test_validate_v2_hard_rejects_dot_mount_dirs() {
1096        let temp_dir = tempfile::TempDir::new().unwrap();
1097        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1098        let cfg = RepoConfigV2 {
1099            version: "2.0".to_string(),
1100            mount_dirs: MountDirsV2 {
1101                thoughts: ".".to_string(),
1102                context: "context".to_string(),
1103                references: "references".to_string(),
1104            },
1105            thoughts_mount: None,
1106            context_mounts: vec![],
1107            references: vec![],
1108        };
1109        let result = mgr.validate_v2_hard(&cfg);
1110        assert!(result.is_err());
1111        assert!(
1112            result
1113                .unwrap_err()
1114                .to_string()
1115                .contains("cannot be '.' or '..'")
1116        );
1117    }
1118
1119    #[test]
1120    fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
1121        let temp_dir = tempfile::TempDir::new().unwrap();
1122        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1123        let cfg = RepoConfigV2 {
1124            version: "2.0".to_string(),
1125            mount_dirs: MountDirsV2 {
1126                thoughts: "sub/path".to_string(),
1127                context: "context".to_string(),
1128                references: "references".to_string(),
1129            },
1130            thoughts_mount: None,
1131            context_mounts: vec![],
1132            references: vec![],
1133        };
1134        let result = mgr.validate_v2_hard(&cfg);
1135        assert!(result.is_err());
1136        assert!(
1137            result
1138                .unwrap_err()
1139                .to_string()
1140                .contains("must be a single path segment")
1141        );
1142    }
1143
1144    #[test]
1145    fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
1146        let temp_dir = tempfile::TempDir::new().unwrap();
1147        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1148        let cfg = RepoConfigV2 {
1149            version: "2.0".to_string(),
1150            mount_dirs: MountDirsV2 {
1151                thoughts: "same".to_string(),
1152                context: "same".to_string(),
1153                references: "references".to_string(),
1154            },
1155            thoughts_mount: None,
1156            context_mounts: vec![],
1157            references: vec![],
1158        };
1159        let result = mgr.validate_v2_hard(&cfg);
1160        assert!(result.is_err());
1161        assert!(result.unwrap_err().to_string().contains("must be distinct"));
1162    }
1163
1164    #[test]
1165    fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
1166        let temp_dir = tempfile::TempDir::new().unwrap();
1167        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1168        let cfg = RepoConfigV2 {
1169            version: "2.0".to_string(),
1170            mount_dirs: MountDirsV2::default(),
1171            thoughts_mount: Some(ThoughtsMount {
1172                remote: "invalid-url".to_string(),
1173                subpath: None,
1174                sync: SyncStrategy::Auto,
1175            }),
1176            context_mounts: vec![],
1177            references: vec![],
1178        };
1179        let result = mgr.validate_v2_hard(&cfg);
1180        assert!(result.is_err());
1181        assert!(
1182            result
1183                .unwrap_err()
1184                .to_string()
1185                .contains("Invalid remote URL")
1186        );
1187    }
1188
1189    #[test]
1190    fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
1191        let temp_dir = tempfile::TempDir::new().unwrap();
1192        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1193        let cfg = RepoConfigV2 {
1194            version: "2.0".to_string(),
1195            mount_dirs: MountDirsV2::default(),
1196            thoughts_mount: None,
1197            context_mounts: vec![
1198                ContextMount {
1199                    remote: "git@github.com:org/repo1.git".to_string(),
1200                    subpath: None,
1201                    mount_path: "same".to_string(),
1202                    sync: SyncStrategy::Auto,
1203                },
1204                ContextMount {
1205                    remote: "git@github.com:org/repo2.git".to_string(),
1206                    subpath: None,
1207                    mount_path: "same".to_string(),
1208                    sync: SyncStrategy::Auto,
1209                },
1210            ],
1211            references: vec![],
1212        };
1213        let result = mgr.validate_v2_hard(&cfg);
1214        assert!(result.is_err());
1215        assert!(
1216            result
1217                .unwrap_err()
1218                .to_string()
1219                .contains("Duplicate context mount path")
1220        );
1221    }
1222
1223    #[test]
1224    fn test_validate_v2_hard_rejects_invalid_context_remote() {
1225        let temp_dir = tempfile::TempDir::new().unwrap();
1226        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1227        let cfg = RepoConfigV2 {
1228            version: "2.0".to_string(),
1229            mount_dirs: MountDirsV2::default(),
1230            thoughts_mount: None,
1231            context_mounts: vec![ContextMount {
1232                remote: "invalid-url".to_string(),
1233                subpath: None,
1234                mount_path: "mount1".to_string(),
1235                sync: SyncStrategy::Auto,
1236            }],
1237            references: vec![],
1238        };
1239        let result = mgr.validate_v2_hard(&cfg);
1240        assert!(result.is_err());
1241        assert!(
1242            result
1243                .unwrap_err()
1244                .to_string()
1245                .contains("Invalid remote URL")
1246        );
1247    }
1248
1249    #[test]
1250    fn test_validate_v2_hard_warns_on_sync_none() {
1251        let temp_dir = tempfile::TempDir::new().unwrap();
1252        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1253        let cfg = RepoConfigV2 {
1254            version: "2.0".to_string(),
1255            mount_dirs: MountDirsV2::default(),
1256            thoughts_mount: None,
1257            context_mounts: vec![ContextMount {
1258                remote: "git@github.com:org/repo.git".to_string(),
1259                subpath: None,
1260                mount_path: "mount1".to_string(),
1261                sync: SyncStrategy::None,
1262            }],
1263            references: vec![],
1264        };
1265        let result = mgr.validate_v2_hard(&cfg);
1266        assert!(result.is_ok());
1267        let warnings = result.unwrap();
1268        assert_eq!(warnings.len(), 1);
1269        assert!(warnings[0].contains("sync:None"));
1270        assert!(warnings[0].contains("discouraged"));
1271    }
1272
1273    #[test]
1274    fn test_validate_v2_hard_rejects_invalid_reference_url() {
1275        let temp_dir = tempfile::TempDir::new().unwrap();
1276        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1277        let cfg = RepoConfigV2 {
1278            version: "2.0".to_string(),
1279            mount_dirs: MountDirsV2::default(),
1280            thoughts_mount: None,
1281            context_mounts: vec![],
1282            references: vec![ReferenceEntry::Simple(
1283                "git@github.com:org/repo.git:subpath".to_string(),
1284            )],
1285        };
1286        let result = mgr.validate_v2_hard(&cfg);
1287        assert!(result.is_err());
1288        assert!(result.unwrap_err().to_string().contains("subpath"));
1289    }
1290
1291    #[test]
1292    fn test_validate_v2_hard_rejects_duplicate_references() {
1293        let temp_dir = tempfile::TempDir::new().unwrap();
1294        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1295        let cfg = RepoConfigV2 {
1296            version: "2.0".to_string(),
1297            mount_dirs: MountDirsV2::default(),
1298            thoughts_mount: None,
1299            context_mounts: vec![],
1300            references: vec![
1301                ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
1302                ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
1303            ],
1304        };
1305        let result = mgr.validate_v2_hard(&cfg);
1306        assert!(result.is_err());
1307        assert!(
1308            result
1309                .unwrap_err()
1310                .to_string()
1311                .contains("Duplicate reference")
1312        );
1313    }
1314
1315    #[test]
1316    fn test_validate_v2_hard_allows_same_repo_with_different_refs() {
1317        let temp_dir = tempfile::TempDir::new().unwrap();
1318        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1319        let cfg = RepoConfigV2 {
1320            version: "2.0".to_string(),
1321            mount_dirs: MountDirsV2::default(),
1322            thoughts_mount: None,
1323            context_mounts: vec![],
1324            references: vec![
1325                ReferenceEntry::WithMetadata(ReferenceMount {
1326                    remote: "https://github.com/org/repo".to_string(),
1327                    description: None,
1328                    ref_name: Some("refs/heads/main".to_string()),
1329                }),
1330                ReferenceEntry::WithMetadata(ReferenceMount {
1331                    remote: "git@github.com:Org/Repo.git".to_string(),
1332                    description: None,
1333                    ref_name: Some("refs/tags/v1.0.0".to_string()),
1334                }),
1335            ],
1336        };
1337
1338        let result = mgr.validate_v2_hard(&cfg);
1339        assert!(result.is_ok());
1340    }
1341
1342    #[test]
1343    fn test_validate_v2_hard_rejects_duplicate_references_with_same_ref() {
1344        let temp_dir = tempfile::TempDir::new().unwrap();
1345        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1346        let cfg = RepoConfigV2 {
1347            version: "2.0".to_string(),
1348            mount_dirs: MountDirsV2::default(),
1349            thoughts_mount: None,
1350            context_mounts: vec![],
1351            references: vec![
1352                ReferenceEntry::WithMetadata(ReferenceMount {
1353                    remote: "https://github.com/org/repo".to_string(),
1354                    description: None,
1355                    ref_name: Some("refs/heads/main".to_string()),
1356                }),
1357                ReferenceEntry::WithMetadata(ReferenceMount {
1358                    remote: "git@github.com:Org/Repo.git".to_string(),
1359                    description: Some("duplicate".to_string()),
1360                    ref_name: Some("refs/heads/main".to_string()),
1361                }),
1362            ],
1363        };
1364
1365        let result = mgr.validate_v2_hard(&cfg);
1366        assert!(result.is_err());
1367        assert!(
1368            result
1369                .unwrap_err()
1370                .to_string()
1371                .contains("Duplicate reference")
1372        );
1373    }
1374
1375    #[test]
1376    fn test_validate_v2_hard_rejects_duplicate_references_legacy_remotes_vs_heads() {
1377        let temp_dir = tempfile::TempDir::new().unwrap();
1378        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1379
1380        let cfg = RepoConfigV2 {
1381            version: "2.0".to_string(),
1382            mount_dirs: MountDirsV2::default(),
1383            thoughts_mount: None,
1384            context_mounts: vec![],
1385            references: vec![
1386                ReferenceEntry::WithMetadata(ReferenceMount {
1387                    remote: "https://github.com/org/repo".to_string(),
1388                    description: None,
1389                    ref_name: Some("refs/remotes/origin/main".to_string()),
1390                }),
1391                ReferenceEntry::WithMetadata(ReferenceMount {
1392                    remote: "git@github.com:Org/Repo.git".to_string(),
1393                    description: None,
1394                    ref_name: Some("refs/heads/main".to_string()),
1395                }),
1396            ],
1397        };
1398
1399        let err = mgr.validate_v2_hard(&cfg).unwrap_err();
1400        assert!(err.to_string().contains("Duplicate reference"));
1401    }
1402
1403    #[test]
1404    fn test_validate_v2_hard_rejects_shorthand_pinned_ref() {
1405        let temp_dir = tempfile::TempDir::new().unwrap();
1406        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1407
1408        let cfg = RepoConfigV2 {
1409            version: "2.0".to_string(),
1410            mount_dirs: MountDirsV2::default(),
1411            thoughts_mount: None,
1412            context_mounts: vec![],
1413            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1414                remote: "https://github.com/org/repo".to_string(),
1415                description: None,
1416                ref_name: Some("main".to_string()),
1417            })],
1418        };
1419
1420        let err = mgr.validate_v2_hard(&cfg).unwrap_err();
1421        assert!(
1422            format!("{err:#}").contains("Pinned refs must be full ref names"),
1423            "unexpected error chain: {err:#}"
1424        );
1425    }
1426
1427    #[test]
1428    fn test_validate_v2_hard_rejects_incomplete_pinned_ref_prefix() {
1429        let temp_dir = tempfile::TempDir::new().unwrap();
1430        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1431
1432        let cfg = RepoConfigV2 {
1433            version: "2.0".to_string(),
1434            mount_dirs: MountDirsV2::default(),
1435            thoughts_mount: None,
1436            context_mounts: vec![],
1437            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1438                remote: "https://github.com/org/repo".to_string(),
1439                description: None,
1440                ref_name: Some("refs/heads/".to_string()),
1441            })],
1442        };
1443
1444        assert!(mgr.validate_v2_hard(&cfg).is_err());
1445    }
1446
1447    #[test]
1448    fn test_validate_v2_hard_warns_on_legacy_refs_remotes() {
1449        let temp_dir = tempfile::TempDir::new().unwrap();
1450        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1451
1452        let cfg = RepoConfigV2 {
1453            version: "2.0".to_string(),
1454            mount_dirs: MountDirsV2::default(),
1455            thoughts_mount: None,
1456            context_mounts: vec![],
1457            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1458                remote: "https://github.com/org/repo".to_string(),
1459                description: None,
1460                ref_name: Some("refs/remotes/origin/main".to_string()),
1461            })],
1462        };
1463
1464        let warnings = mgr
1465            .validate_v2_hard(&cfg)
1466            .expect("legacy refs/remotes should warn");
1467        assert_eq!(warnings.len(), 1);
1468        assert!(warnings[0].contains("refs/remotes/origin/main"));
1469        assert!(warnings[0].contains("legacy pinned ref"));
1470    }
1471
1472    #[test]
1473    fn test_validate_v2_hard_accepts_valid_config() {
1474        let temp_dir = tempfile::TempDir::new().unwrap();
1475        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1476        let cfg = RepoConfigV2 {
1477            version: "2.0".to_string(),
1478            mount_dirs: MountDirsV2::default(),
1479            thoughts_mount: Some(ThoughtsMount {
1480                remote: "git@github.com:user/thoughts.git".to_string(),
1481                subpath: None,
1482                sync: SyncStrategy::Auto,
1483            }),
1484            context_mounts: vec![ContextMount {
1485                remote: "git@github.com:org/context.git".to_string(),
1486                subpath: Some("docs".to_string()),
1487                mount_path: "docs".to_string(),
1488                sync: SyncStrategy::Auto,
1489            }],
1490            references: vec![
1491                ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1492                ReferenceEntry::WithMetadata(ReferenceMount {
1493                    remote: "https://github.com/org/repo2".to_string(),
1494                    description: Some("Reference 2".to_string()),
1495                    ref_name: None,
1496                }),
1497            ],
1498        };
1499        let result = mgr.validate_v2_hard(&cfg);
1500        assert!(result.is_ok());
1501        let warnings = result.unwrap();
1502        assert_eq!(warnings.len(), 0);
1503    }
1504
1505    #[test]
1506    fn test_save_v2_validated_fails_before_write_on_invalid() {
1507        let temp_dir = tempfile::TempDir::new().unwrap();
1508        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1509        let cfg = RepoConfigV2 {
1510            version: "2.0".to_string(),
1511            mount_dirs: MountDirsV2 {
1512                thoughts: "same".to_string(),
1513                context: "same".to_string(),
1514                references: "references".to_string(),
1515            },
1516            thoughts_mount: None,
1517            context_mounts: vec![],
1518            references: vec![],
1519        };
1520
1521        let result = mgr.save_v2_validated(&cfg);
1522        assert!(result.is_err());
1523
1524        // Verify no file was written
1525        let config_path = paths::get_repo_config_path(temp_dir.path());
1526        assert!(!config_path.exists());
1527    }
1528
1529    #[test]
1530    fn test_save_v2_validated_returns_warnings_on_valid() {
1531        let temp_dir = tempfile::TempDir::new().unwrap();
1532        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1533        let cfg = RepoConfigV2 {
1534            version: "2.0".to_string(),
1535            mount_dirs: MountDirsV2::default(),
1536            thoughts_mount: None,
1537            context_mounts: vec![ContextMount {
1538                remote: "git@github.com:org/repo.git".to_string(),
1539                subpath: None,
1540                mount_path: "mount1".to_string(),
1541                sync: SyncStrategy::None,
1542            }],
1543            references: vec![],
1544        };
1545
1546        let result = mgr.save_v2_validated(&cfg);
1547        assert!(result.is_ok());
1548        let warnings = result.unwrap();
1549        assert_eq!(warnings.len(), 1);
1550        assert!(warnings[0].contains("sync:None"));
1551
1552        // Verify file was written
1553        let config_path = paths::get_repo_config_path(temp_dir.path());
1554        assert!(config_path.exists());
1555    }
1556
1557    #[test]
1558    fn test_ensure_v2_default_migrates_v1_without_panic() {
1559        let temp_dir = tempfile::TempDir::new().unwrap();
1560        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1561
1562        // Create v1 config with one context mount and one reference
1563        let v1_config = RepoConfig {
1564            version: "1.0".to_string(),
1565            mount_dirs: MountDirs {
1566                repository: "mycontext".to_string(),
1567                personal: "personal".to_string(),
1568            },
1569            requires: vec![
1570                RequiredMount {
1571                    remote: "git@github.com:org/context-repo.git".to_string(),
1572                    mount_path: "docs".to_string(),
1573                    subpath: Some("content".to_string()),
1574                    description: "Documentation".to_string(),
1575                    optional: false,
1576                    override_rules: None,
1577                    sync: SyncStrategy::Auto,
1578                },
1579                RequiredMount {
1580                    remote: "git@github.com:org/ref-repo.git".to_string(),
1581                    mount_path: "references/ref".to_string(),
1582                    subpath: None,
1583                    description: "Reference repo".to_string(),
1584                    optional: true,
1585                    override_rules: None,
1586                    sync: SyncStrategy::None,
1587                },
1588            ],
1589            rules: vec![],
1590        };
1591
1592        // Save v1 config
1593        mgr.save(&v1_config).unwrap();
1594
1595        // Call ensure_v2_default() - should migrate without panic
1596        let v2_config = mgr.ensure_v2_default().unwrap();
1597
1598        // Verify migration succeeded
1599        assert_eq!(v2_config.version, "2.0");
1600        assert_eq!(v2_config.mount_dirs.context, "mycontext");
1601        assert_eq!(v2_config.context_mounts.len(), 1);
1602        assert_eq!(v2_config.context_mounts[0].mount_path, "docs");
1603        assert_eq!(
1604            v2_config.context_mounts[0].subpath,
1605            Some("content".to_string())
1606        );
1607        assert_eq!(v2_config.references.len(), 1);
1608
1609        // Verify config file was updated to v2
1610        let ds = mgr.load_desired_state().unwrap().unwrap();
1611        assert!(!ds.was_v1); // Now it's v2 on disk
1612        assert_eq!(ds.context_mounts.len(), 1);
1613        assert_eq!(ds.references.len(), 1);
1614    }
1615
1616    #[test]
1617    fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1618        let temp_dir = tempfile::TempDir::new().unwrap();
1619        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1620        let cfg = RepoConfigV2 {
1621            version: "2.0".to_string(),
1622            mount_dirs: MountDirsV2::default(),
1623            thoughts_mount: None,
1624            context_mounts: vec![ContextMount {
1625                remote: "git@github.com:org/repo.git".to_string(),
1626                subpath: None,
1627                mount_path: "  ".to_string(), // whitespace-only
1628                sync: SyncStrategy::Auto,
1629            }],
1630            references: vec![],
1631        };
1632        let result = mgr.validate_v2_hard(&cfg);
1633        assert!(result.is_err());
1634        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1635    }
1636
1637    #[test]
1638    fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1639        let temp_dir = tempfile::TempDir::new().unwrap();
1640        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1641        let cfg = RepoConfigV2 {
1642            version: "2.0".to_string(),
1643            mount_dirs: MountDirsV2::default(),
1644            thoughts_mount: None,
1645            context_mounts: vec![ContextMount {
1646                remote: "git@github.com:org/repo.git".to_string(),
1647                subpath: None,
1648                mount_path: ".".to_string(),
1649                sync: SyncStrategy::Auto,
1650            }],
1651            references: vec![],
1652        };
1653        let result = mgr.validate_v2_hard(&cfg);
1654        assert!(result.is_err());
1655        assert!(
1656            result
1657                .unwrap_err()
1658                .to_string()
1659                .contains("cannot be '.' or '..'")
1660        );
1661    }
1662
1663    #[test]
1664    fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1665        let temp_dir = tempfile::TempDir::new().unwrap();
1666        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1667        let cfg = RepoConfigV2 {
1668            version: "2.0".to_string(),
1669            mount_dirs: MountDirsV2::default(),
1670            thoughts_mount: None,
1671            context_mounts: vec![ContextMount {
1672                remote: "git@github.com:org/repo.git".to_string(),
1673                subpath: None,
1674                mount_path: "..".to_string(),
1675                sync: SyncStrategy::Auto,
1676            }],
1677            references: vec![],
1678        };
1679        let result = mgr.validate_v2_hard(&cfg);
1680        assert!(result.is_err());
1681        assert!(
1682            result
1683                .unwrap_err()
1684                .to_string()
1685                .contains("cannot be '.' or '..'")
1686        );
1687    }
1688
1689    #[test]
1690    fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1691        let temp_dir = tempfile::TempDir::new().unwrap();
1692        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1693        let cfg = RepoConfigV2 {
1694            version: "2.0".to_string(),
1695            mount_dirs: MountDirsV2::default(),
1696            thoughts_mount: None,
1697            context_mounts: vec![ContextMount {
1698                remote: "git@github.com:org/repo.git".to_string(),
1699                subpath: None,
1700                mount_path: "sub/path".to_string(),
1701                sync: SyncStrategy::Auto,
1702            }],
1703            references: vec![],
1704        };
1705        let result = mgr.validate_v2_hard(&cfg);
1706        assert!(result.is_err());
1707        assert!(
1708            result
1709                .unwrap_err()
1710                .to_string()
1711                .contains("single path segment")
1712        );
1713    }
1714
1715    #[test]
1716    fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1717        let temp_dir = tempfile::TempDir::new().unwrap();
1718        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1719        let cfg = RepoConfigV2 {
1720            version: "2.0".to_string(),
1721            mount_dirs: MountDirsV2::default(),
1722            thoughts_mount: None,
1723            context_mounts: vec![ContextMount {
1724                remote: "git@github.com:org/repo.git".to_string(),
1725                subpath: None,
1726                mount_path: "sub\\path".to_string(),
1727                sync: SyncStrategy::Auto,
1728            }],
1729            references: vec![],
1730        };
1731        let result = mgr.validate_v2_hard(&cfg);
1732        assert!(result.is_err());
1733        assert!(
1734            result
1735                .unwrap_err()
1736                .to_string()
1737                .contains("single path segment")
1738        );
1739    }
1740
1741    #[test]
1742    fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1743        let temp_dir = tempfile::TempDir::new().unwrap();
1744        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1745        let cfg = RepoConfigV2 {
1746            version: "2.0".to_string(),
1747            mount_dirs: MountDirsV2::default(),
1748            thoughts_mount: None,
1749            context_mounts: vec![ContextMount {
1750                remote: "git@github.com:org/repo.git".to_string(),
1751                subpath: None,
1752                mount_path: "docs".to_string(),
1753                sync: SyncStrategy::Auto,
1754            }],
1755            references: vec![],
1756        };
1757        let result = mgr.validate_v2_hard(&cfg);
1758        assert!(result.is_ok());
1759        let warnings = result.unwrap();
1760        assert_eq!(warnings.len(), 0);
1761    }
1762
1763    #[test]
1764    fn test_new_makes_absolute_when_given_relative_repo_root() {
1765        let temp_dir = TempDir::new().unwrap();
1766        let cwd_before = std::env::current_dir().unwrap();
1767
1768        // Change cwd to temp_dir so a relative path exists
1769        std::env::set_current_dir(temp_dir.path()).unwrap();
1770
1771        // Create a subdir to use as repo root
1772        std::fs::create_dir_all("repo").unwrap();
1773
1774        let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1775
1776        // repo_root field is private but we can verify via behavior
1777        // The test passes if construction succeeds (no panic) and
1778        // subsequent operations work correctly
1779        assert!(mgr.peek_config_version().is_ok());
1780
1781        // Restore cwd
1782        std::env::set_current_dir(cwd_before).unwrap();
1783    }
1784
1785    /// Regression test: validate_v2_hard() rejects mount directories with trailing slashes.
1786    ///
1787    /// This documents the invariant that the "single path segment" validation at lines 474-479
1788    /// implicitly blocks trailing slashes (which contain '/'). This invariant protects against
1789    /// a latent bug in fmt.rs path stripping where `format!("{}/", base)` would produce double
1790    /// slashes if `base` already ended with '/'.
1791    #[test]
1792    fn test_validate_v2_hard_rejects_trailing_slash_in_mount_dirs() {
1793        let temp_dir = tempfile::TempDir::new().unwrap();
1794        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1795
1796        // Test trailing slash on thoughts mount dir
1797        let cfg = RepoConfigV2 {
1798            version: "2.0".to_string(),
1799            mount_dirs: MountDirsV2 {
1800                thoughts: "thoughts/".to_string(),
1801                context: "context".to_string(),
1802                references: "references".to_string(),
1803            },
1804            thoughts_mount: None,
1805            context_mounts: vec![],
1806            references: vec![],
1807        };
1808        let result = mgr.validate_v2_hard(&cfg);
1809        assert!(
1810            result.is_err(),
1811            "trailing slash on thoughts should be rejected"
1812        );
1813        assert!(
1814            result
1815                .unwrap_err()
1816                .to_string()
1817                .contains("single path segment"),
1818            "error should mention single path segment requirement"
1819        );
1820
1821        // Test trailing slash on context mount dir
1822        let cfg = RepoConfigV2 {
1823            version: "2.0".to_string(),
1824            mount_dirs: MountDirsV2 {
1825                thoughts: "thoughts".to_string(),
1826                context: "context/".to_string(),
1827                references: "references".to_string(),
1828            },
1829            thoughts_mount: None,
1830            context_mounts: vec![],
1831            references: vec![],
1832        };
1833        let result = mgr.validate_v2_hard(&cfg);
1834        assert!(
1835            result.is_err(),
1836            "trailing slash on context should be rejected"
1837        );
1838
1839        // Test trailing slash on references mount dir
1840        let cfg = RepoConfigV2 {
1841            version: "2.0".to_string(),
1842            mount_dirs: MountDirsV2 {
1843                thoughts: "thoughts".to_string(),
1844                context: "context".to_string(),
1845                references: "references/".to_string(),
1846            },
1847            thoughts_mount: None,
1848            context_mounts: vec![],
1849            references: vec![],
1850        };
1851        let result = mgr.validate_v2_hard(&cfg);
1852        assert!(
1853            result.is_err(),
1854            "trailing slash on references should be rejected"
1855        );
1856    }
1857}