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