Skip to main content

thoughts_tool/config/
repo_manager.rs

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