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, }
28
29impl DesiredState {
30 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 None
55 }
56 }
57 }
58
59 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 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 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 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 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 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 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 pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
173 let config_path = paths::get_repo_config_path(&self.repo_root);
174
175 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 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 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 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 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 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 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 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 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 == ".thoughts-data" {
274 anyhow::bail!("Mount directory '{name}' cannot be named '.thoughts-data'");
275 }
276 if val == "." || val == ".." {
277 anyhow::bail!("Mount directory '{name}' cannot be '.' or '..'");
278 }
279 if val.contains('/') || val.contains('\\') {
280 anyhow::bail!("Mount directory '{name}' must be a single path segment (got {val})");
281 }
282 }
283 if m.thoughts == m.context || m.thoughts == m.references || m.context == m.references {
284 anyhow::bail!("Mount directories must be distinct (thoughts/context/references)");
285 }
286
287 if let Some(tm) = &cfg.thoughts_mount {
289 Self::validate_remote(&tm.remote)?;
290 }
291
292 let mut warnings = Vec::new();
294 let mut seen_mount_paths = std::collections::HashSet::new();
295 for cm in &cfg.context_mounts {
296 if !seen_mount_paths.insert(&cm.mount_path) {
298 anyhow::bail!("Duplicate context mount path: {}", cm.mount_path);
299 }
300
301 let mp = cm.mount_path.trim();
303 if mp.is_empty() {
304 anyhow::bail!("Context mount path cannot be empty");
305 }
306 if mp == "." || mp == ".." {
307 anyhow::bail!("Context mount path cannot be '.' or '..'");
308 }
309 if mp.contains('/') || mp.contains('\\') {
310 anyhow::bail!(
311 "Context mount path must be a single path segment (got {})",
312 cm.mount_path
313 );
314 }
315 let m = &cfg.mount_dirs;
316 if mp == m.thoughts || mp == m.context || mp == m.references {
317 anyhow::bail!(
318 "Context mount path '{}' cannot conflict with configured mount_dirs names ('{}', '{}', '{}')",
319 cm.mount_path,
320 m.thoughts,
321 m.context,
322 m.references
323 );
324 }
325
326 Self::validate_remote(&cm.remote)?;
328 if matches!(cm.sync, SyncStrategy::None) {
329 warnings.push(format!(
330 "Context mount '{}' has sync:None; allowed but discouraged. Consider SyncStrategy::Auto.",
331 cm.mount_path
332 ));
333 }
334 }
335
336 let mut seen_refs = std::collections::HashSet::new();
338 for r in &cfg.references {
339 let (url, ref_name) = match r {
340 ReferenceEntry::Simple(s) => (s.as_str(), None),
341 ReferenceEntry::WithMetadata(rm) => (rm.remote.as_str(), rm.ref_name.as_deref()),
342 };
343 validate_reference_url(url).with_context(|| format!("Invalid reference '{url}'"))?;
344 if let Some(ref_name) = ref_name {
345 if ref_name.starts_with("refs/remotes/") {
346 warnings.push(format!(
347 "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."
348 ));
349 }
350 validate_pinned_ref_full_name(ref_name).with_context(|| {
351 format!("Invalid pinned ref '{ref_name}' for reference '{url}'")
352 })?;
353 }
354 let key = canonical_reference_instance_key(url, ref_name)?;
355 if !seen_refs.insert(key) {
356 anyhow::bail!("Duplicate reference detected: {url}");
357 }
358 }
359
360 Ok(warnings)
361 }
362
363 pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
365 let warnings = self.validate_v2_hard(config)?;
366 self.save_v2(config)?;
367 Ok(warnings)
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::utils::paths;
375 use tempfile::TempDir;
376
377 #[test]
378 fn test_load_desired_state_rejects_v1() {
379 let temp_dir = TempDir::new().unwrap();
380 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
381
382 let v1_json = r#"{
384 "version": "1.0",
385 "mount_dirs": {"repository": "context", "personal": "personal"},
386 "requires": [],
387 "rules": []
388 }"#;
389
390 let config_path = paths::get_repo_config_path(temp_dir.path());
391 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
392 std::fs::write(&config_path, v1_json).unwrap();
393
394 let result = manager.load_desired_state();
396 assert!(result.is_err());
397 assert!(result.unwrap_err().to_string().contains("v1"));
398 }
399
400 #[test]
401 fn test_v2_config_loading() {
402 let temp_dir = TempDir::new().unwrap();
403 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
404
405 let v2_config = crate::config::RepoConfigV2 {
407 version: "2.0".to_string(),
408 mount_dirs: crate::config::MountDirsV2::default(),
409 thoughts_mount: Some(crate::config::ThoughtsMount {
410 remote: "git@github.com:user/thoughts.git".to_string(),
411 subpath: None,
412 sync: crate::config::SyncStrategy::Auto,
413 }),
414 context_mounts: vec![crate::config::ContextMount {
415 remote: "git@github.com:user/context.git".to_string(),
416 subpath: Some("docs".to_string()),
417 mount_path: "docs".to_string(),
418 sync: crate::config::SyncStrategy::Auto,
419 }],
420 references: vec![
421 ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
422 ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
423 ],
424 };
425
426 let config_path = paths::get_repo_config_path(temp_dir.path());
428 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
429 let json = serde_json::to_string_pretty(&v2_config).unwrap();
430 std::fs::write(&config_path, json).unwrap();
431
432 let desired_state = manager.load_desired_state().unwrap().unwrap();
434
435 assert!(!desired_state.was_v1);
437 assert!(desired_state.thoughts_mount.is_some());
438 assert_eq!(
439 desired_state.thoughts_mount.as_ref().unwrap().remote,
440 "git@github.com:user/thoughts.git"
441 );
442 assert_eq!(desired_state.context_mounts.len(), 1);
443 assert_eq!(desired_state.references.len(), 2);
444 }
445
446 #[test]
447 fn test_v2_references_normalize_to_reference_mount() {
448 let temp_dir = TempDir::new().unwrap();
449 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
450
451 let json = r#"{
452 "version": "2.0",
453 "mount_dirs": {},
454 "context_mounts": [],
455 "references": [
456 "git@github.com:org/ref1.git",
457 {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
458 ]
459 }"#;
460
461 let config_path = paths::get_repo_config_path(temp_dir.path());
462 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
463 std::fs::write(&config_path, json).unwrap();
464
465 let ds = manager.load_desired_state().unwrap().unwrap();
466 assert_eq!(ds.references.len(), 2);
467 assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
468 assert_eq!(ds.references[0].description, None);
469 assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
470 assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
471 }
472
473 #[test]
474 fn test_validate_v2_soft_handles_both_variants() {
475 let temp_dir = tempfile::TempDir::new().unwrap();
476 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
477
478 let cfg = RepoConfigV2 {
479 version: "2.0".into(),
480 mount_dirs: MountDirsV2::default(),
481 thoughts_mount: None,
482 context_mounts: vec![],
483 references: vec![
484 ReferenceEntry::Simple("https://github.com/org/repo".into()),
485 ReferenceEntry::WithMetadata(ReferenceMount {
486 remote: "git@github.com:org/repo.git:docs".into(), description: None,
488 ref_name: None,
489 }),
490 ],
491 };
492
493 let warnings = mgr.validate_v2_soft(&cfg);
494 assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
495 assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
496 }
497
498 #[test]
499 fn test_peek_config_version_returns_none_when_no_config() {
500 let temp_dir = tempfile::TempDir::new().unwrap();
501 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
502 assert_eq!(mgr.peek_config_version().unwrap(), None);
503 }
504
505 #[test]
506 fn test_peek_config_version_returns_v1() {
507 let temp_dir = tempfile::TempDir::new().unwrap();
508 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
509
510 let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
512 let config_path = paths::get_repo_config_path(temp_dir.path());
513 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
514 std::fs::write(&config_path, v1_json).unwrap();
515
516 assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
517 }
518
519 #[test]
520 fn test_peek_config_version_returns_v2() {
521 let temp_dir = tempfile::TempDir::new().unwrap();
522 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
523 let v2_config = RepoConfigV2 {
524 version: "2.0".to_string(),
525 mount_dirs: MountDirsV2::default(),
526 thoughts_mount: None,
527 context_mounts: vec![],
528 references: vec![],
529 };
530 mgr.save_v2(&v2_config).unwrap();
531 assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
532 }
533
534 #[test]
535 fn test_validate_v2_hard_rejects_invalid_version() {
536 let temp_dir = tempfile::TempDir::new().unwrap();
537 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
538 let cfg = RepoConfigV2 {
539 version: "3.0".to_string(),
540 mount_dirs: MountDirsV2::default(),
541 thoughts_mount: None,
542 context_mounts: vec![],
543 references: vec![],
544 };
545 let result = mgr.validate_v2_hard(&cfg);
546 assert!(result.is_err());
547 assert!(
548 result
549 .unwrap_err()
550 .to_string()
551 .contains("Unsupported configuration version: 3.0")
552 );
553 }
554
555 #[test]
556 fn test_validate_v2_hard_rejects_pinned_ref_with_whitespace_or_trailing_slash() {
557 let temp_dir = tempfile::TempDir::new().unwrap();
558 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
559
560 for bad_ref in [" refs/heads/main", "refs/heads/main ", "refs/heads/main/"] {
561 let cfg = RepoConfigV2 {
562 version: "2.0".to_string(),
563 mount_dirs: MountDirsV2::default(),
564 thoughts_mount: None,
565 context_mounts: vec![],
566 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
567 remote: "https://github.com/org/repo".to_string(),
568 description: None,
569 ref_name: Some(bad_ref.to_string()),
570 })],
571 };
572
573 assert!(
574 mgr.validate_v2_hard(&cfg).is_err(),
575 "expected {bad_ref:?} to fail hard validation"
576 );
577 }
578 }
579
580 #[test]
581 fn test_validate_v2_hard_rejects_empty_mount_dirs() {
582 let temp_dir = tempfile::TempDir::new().unwrap();
583 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
584 let cfg = RepoConfigV2 {
585 version: "2.0".to_string(),
586 mount_dirs: MountDirsV2 {
587 thoughts: String::new(),
588 context: "context".to_string(),
589 references: "references".to_string(),
590 },
591 thoughts_mount: None,
592 context_mounts: vec![],
593 references: vec![],
594 };
595 let result = mgr.validate_v2_hard(&cfg);
596 assert!(result.is_err());
597 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
598 }
599
600 #[test]
601 fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
602 let temp_dir = tempfile::TempDir::new().unwrap();
603 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
604 let cfg = RepoConfigV2 {
605 version: "2.0".to_string(),
606 mount_dirs: MountDirsV2 {
607 thoughts: ".thoughts-data".to_string(),
608 context: "context".to_string(),
609 references: "references".to_string(),
610 },
611 thoughts_mount: None,
612 context_mounts: vec![],
613 references: vec![],
614 };
615 let result = mgr.validate_v2_hard(&cfg);
616 assert!(result.is_err());
617 assert!(result.unwrap_err().to_string().contains(".thoughts-data"));
618 }
619
620 #[test]
621 fn test_validate_v2_hard_rejects_dot_mount_dirs() {
622 let temp_dir = tempfile::TempDir::new().unwrap();
623 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
624 let cfg = RepoConfigV2 {
625 version: "2.0".to_string(),
626 mount_dirs: MountDirsV2 {
627 thoughts: ".".to_string(),
628 context: "context".to_string(),
629 references: "references".to_string(),
630 },
631 thoughts_mount: None,
632 context_mounts: vec![],
633 references: vec![],
634 };
635 let result = mgr.validate_v2_hard(&cfg);
636 assert!(result.is_err());
637 assert!(
638 result
639 .unwrap_err()
640 .to_string()
641 .contains("cannot be '.' or '..'")
642 );
643 }
644
645 #[test]
646 fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
647 let temp_dir = tempfile::TempDir::new().unwrap();
648 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
649 let cfg = RepoConfigV2 {
650 version: "2.0".to_string(),
651 mount_dirs: MountDirsV2 {
652 thoughts: "sub/path".to_string(),
653 context: "context".to_string(),
654 references: "references".to_string(),
655 },
656 thoughts_mount: None,
657 context_mounts: vec![],
658 references: vec![],
659 };
660 let result = mgr.validate_v2_hard(&cfg);
661 assert!(result.is_err());
662 assert!(
663 result
664 .unwrap_err()
665 .to_string()
666 .contains("must be a single path segment")
667 );
668 }
669
670 #[test]
671 fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
672 let temp_dir = tempfile::TempDir::new().unwrap();
673 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
674 let cfg = RepoConfigV2 {
675 version: "2.0".to_string(),
676 mount_dirs: MountDirsV2 {
677 thoughts: "same".to_string(),
678 context: "same".to_string(),
679 references: "references".to_string(),
680 },
681 thoughts_mount: None,
682 context_mounts: vec![],
683 references: vec![],
684 };
685 let result = mgr.validate_v2_hard(&cfg);
686 assert!(result.is_err());
687 assert!(result.unwrap_err().to_string().contains("must be distinct"));
688 }
689
690 #[test]
691 fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
692 let temp_dir = tempfile::TempDir::new().unwrap();
693 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
694 let cfg = RepoConfigV2 {
695 version: "2.0".to_string(),
696 mount_dirs: MountDirsV2::default(),
697 thoughts_mount: Some(ThoughtsMount {
698 remote: "invalid-url".to_string(),
699 subpath: None,
700 sync: SyncStrategy::Auto,
701 }),
702 context_mounts: vec![],
703 references: vec![],
704 };
705 let result = mgr.validate_v2_hard(&cfg);
706 assert!(result.is_err());
707 assert!(
708 result
709 .unwrap_err()
710 .to_string()
711 .contains("Invalid remote URL")
712 );
713 }
714
715 #[test]
716 fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
717 let temp_dir = tempfile::TempDir::new().unwrap();
718 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
719 let cfg = RepoConfigV2 {
720 version: "2.0".to_string(),
721 mount_dirs: MountDirsV2::default(),
722 thoughts_mount: None,
723 context_mounts: vec![
724 ContextMount {
725 remote: "git@github.com:org/repo1.git".to_string(),
726 subpath: None,
727 mount_path: "same".to_string(),
728 sync: SyncStrategy::Auto,
729 },
730 ContextMount {
731 remote: "git@github.com:org/repo2.git".to_string(),
732 subpath: None,
733 mount_path: "same".to_string(),
734 sync: SyncStrategy::Auto,
735 },
736 ],
737 references: vec![],
738 };
739 let result = mgr.validate_v2_hard(&cfg);
740 assert!(result.is_err());
741 assert!(
742 result
743 .unwrap_err()
744 .to_string()
745 .contains("Duplicate context mount path")
746 );
747 }
748
749 #[test]
750 fn test_validate_v2_hard_rejects_invalid_context_remote() {
751 let temp_dir = tempfile::TempDir::new().unwrap();
752 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
753 let cfg = RepoConfigV2 {
754 version: "2.0".to_string(),
755 mount_dirs: MountDirsV2::default(),
756 thoughts_mount: None,
757 context_mounts: vec![ContextMount {
758 remote: "invalid-url".to_string(),
759 subpath: None,
760 mount_path: "mount1".to_string(),
761 sync: SyncStrategy::Auto,
762 }],
763 references: vec![],
764 };
765 let result = mgr.validate_v2_hard(&cfg);
766 assert!(result.is_err());
767 assert!(
768 result
769 .unwrap_err()
770 .to_string()
771 .contains("Invalid remote URL")
772 );
773 }
774
775 #[test]
776 fn test_validate_v2_hard_warns_on_sync_none() {
777 let temp_dir = tempfile::TempDir::new().unwrap();
778 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
779 let cfg = RepoConfigV2 {
780 version: "2.0".to_string(),
781 mount_dirs: MountDirsV2::default(),
782 thoughts_mount: None,
783 context_mounts: vec![ContextMount {
784 remote: "git@github.com:org/repo.git".to_string(),
785 subpath: None,
786 mount_path: "mount1".to_string(),
787 sync: SyncStrategy::None,
788 }],
789 references: vec![],
790 };
791 let result = mgr.validate_v2_hard(&cfg);
792 assert!(result.is_ok());
793 let warnings = result.unwrap();
794 assert_eq!(warnings.len(), 1);
795 assert!(warnings[0].contains("sync:None"));
796 assert!(warnings[0].contains("discouraged"));
797 }
798
799 #[test]
800 fn test_validate_v2_hard_rejects_invalid_reference_url() {
801 let temp_dir = tempfile::TempDir::new().unwrap();
802 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
803 let cfg = RepoConfigV2 {
804 version: "2.0".to_string(),
805 mount_dirs: MountDirsV2::default(),
806 thoughts_mount: None,
807 context_mounts: vec![],
808 references: vec![ReferenceEntry::Simple(
809 "git@github.com:org/repo.git:subpath".to_string(),
810 )],
811 };
812 let result = mgr.validate_v2_hard(&cfg);
813 assert!(result.is_err());
814 assert!(result.unwrap_err().to_string().contains("subpath"));
815 }
816
817 #[test]
818 fn test_validate_v2_hard_rejects_duplicate_references() {
819 let temp_dir = tempfile::TempDir::new().unwrap();
820 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
821 let cfg = RepoConfigV2 {
822 version: "2.0".to_string(),
823 mount_dirs: MountDirsV2::default(),
824 thoughts_mount: None,
825 context_mounts: vec![],
826 references: vec![
827 ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
828 ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
829 ],
830 };
831 let result = mgr.validate_v2_hard(&cfg);
832 assert!(result.is_err());
833 assert!(
834 result
835 .unwrap_err()
836 .to_string()
837 .contains("Duplicate reference")
838 );
839 }
840
841 #[test]
842 fn test_validate_v2_hard_allows_same_repo_with_different_refs() {
843 let temp_dir = tempfile::TempDir::new().unwrap();
844 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
845 let cfg = RepoConfigV2 {
846 version: "2.0".to_string(),
847 mount_dirs: MountDirsV2::default(),
848 thoughts_mount: None,
849 context_mounts: vec![],
850 references: vec![
851 ReferenceEntry::WithMetadata(ReferenceMount {
852 remote: "https://github.com/org/repo".to_string(),
853 description: None,
854 ref_name: Some("refs/heads/main".to_string()),
855 }),
856 ReferenceEntry::WithMetadata(ReferenceMount {
857 remote: "git@github.com:Org/Repo.git".to_string(),
858 description: None,
859 ref_name: Some("refs/tags/v1.0.0".to_string()),
860 }),
861 ],
862 };
863
864 let result = mgr.validate_v2_hard(&cfg);
865 assert!(result.is_ok());
866 }
867
868 #[test]
869 fn test_validate_v2_hard_rejects_duplicate_references_with_same_ref() {
870 let temp_dir = tempfile::TempDir::new().unwrap();
871 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
872 let cfg = RepoConfigV2 {
873 version: "2.0".to_string(),
874 mount_dirs: MountDirsV2::default(),
875 thoughts_mount: None,
876 context_mounts: vec![],
877 references: vec![
878 ReferenceEntry::WithMetadata(ReferenceMount {
879 remote: "https://github.com/org/repo".to_string(),
880 description: None,
881 ref_name: Some("refs/heads/main".to_string()),
882 }),
883 ReferenceEntry::WithMetadata(ReferenceMount {
884 remote: "git@github.com:Org/Repo.git".to_string(),
885 description: Some("duplicate".to_string()),
886 ref_name: Some("refs/heads/main".to_string()),
887 }),
888 ],
889 };
890
891 let result = mgr.validate_v2_hard(&cfg);
892 assert!(result.is_err());
893 assert!(
894 result
895 .unwrap_err()
896 .to_string()
897 .contains("Duplicate reference")
898 );
899 }
900
901 #[test]
902 fn test_validate_v2_hard_rejects_duplicate_references_legacy_remotes_vs_heads() {
903 let temp_dir = tempfile::TempDir::new().unwrap();
904 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
905
906 let cfg = RepoConfigV2 {
907 version: "2.0".to_string(),
908 mount_dirs: MountDirsV2::default(),
909 thoughts_mount: None,
910 context_mounts: vec![],
911 references: vec![
912 ReferenceEntry::WithMetadata(ReferenceMount {
913 remote: "https://github.com/org/repo".to_string(),
914 description: None,
915 ref_name: Some("refs/remotes/origin/main".to_string()),
916 }),
917 ReferenceEntry::WithMetadata(ReferenceMount {
918 remote: "git@github.com:Org/Repo.git".to_string(),
919 description: None,
920 ref_name: Some("refs/heads/main".to_string()),
921 }),
922 ],
923 };
924
925 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
926 assert!(err.to_string().contains("Duplicate reference"));
927 }
928
929 #[test]
930 fn test_validate_v2_hard_rejects_shorthand_pinned_ref() {
931 let temp_dir = tempfile::TempDir::new().unwrap();
932 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
933
934 let cfg = RepoConfigV2 {
935 version: "2.0".to_string(),
936 mount_dirs: MountDirsV2::default(),
937 thoughts_mount: None,
938 context_mounts: vec![],
939 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
940 remote: "https://github.com/org/repo".to_string(),
941 description: None,
942 ref_name: Some("main".to_string()),
943 })],
944 };
945
946 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
947 assert!(
948 format!("{err:#}").contains("Pinned refs must be full ref names"),
949 "unexpected error chain: {err:#}"
950 );
951 }
952
953 #[test]
954 fn test_validate_v2_hard_rejects_incomplete_pinned_ref_prefix() {
955 let temp_dir = tempfile::TempDir::new().unwrap();
956 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
957
958 let cfg = RepoConfigV2 {
959 version: "2.0".to_string(),
960 mount_dirs: MountDirsV2::default(),
961 thoughts_mount: None,
962 context_mounts: vec![],
963 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
964 remote: "https://github.com/org/repo".to_string(),
965 description: None,
966 ref_name: Some("refs/heads/".to_string()),
967 })],
968 };
969
970 assert!(mgr.validate_v2_hard(&cfg).is_err());
971 }
972
973 #[test]
974 fn test_validate_v2_hard_warns_on_legacy_refs_remotes() {
975 let temp_dir = tempfile::TempDir::new().unwrap();
976 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
977
978 let cfg = RepoConfigV2 {
979 version: "2.0".to_string(),
980 mount_dirs: MountDirsV2::default(),
981 thoughts_mount: None,
982 context_mounts: vec![],
983 references: vec![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 };
989
990 let warnings = mgr
991 .validate_v2_hard(&cfg)
992 .expect("legacy refs/remotes should warn");
993 assert_eq!(warnings.len(), 1);
994 assert!(warnings[0].contains("refs/remotes/origin/main"));
995 assert!(warnings[0].contains("legacy pinned ref"));
996 }
997
998 #[test]
999 fn test_validate_v2_hard_accepts_valid_config() {
1000 let temp_dir = tempfile::TempDir::new().unwrap();
1001 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1002 let cfg = RepoConfigV2 {
1003 version: "2.0".to_string(),
1004 mount_dirs: MountDirsV2::default(),
1005 thoughts_mount: Some(ThoughtsMount {
1006 remote: "git@github.com:user/thoughts.git".to_string(),
1007 subpath: None,
1008 sync: SyncStrategy::Auto,
1009 }),
1010 context_mounts: vec![ContextMount {
1011 remote: "git@github.com:org/context.git".to_string(),
1012 subpath: Some("docs".to_string()),
1013 mount_path: "docs".to_string(),
1014 sync: SyncStrategy::Auto,
1015 }],
1016 references: vec![
1017 ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1018 ReferenceEntry::WithMetadata(ReferenceMount {
1019 remote: "https://github.com/org/repo2".to_string(),
1020 description: Some("Reference 2".to_string()),
1021 ref_name: None,
1022 }),
1023 ],
1024 };
1025 let result = mgr.validate_v2_hard(&cfg);
1026 assert!(result.is_ok());
1027 let warnings = result.unwrap();
1028 assert_eq!(warnings.len(), 0);
1029 }
1030
1031 #[test]
1032 fn test_save_v2_validated_fails_before_write_on_invalid() {
1033 let temp_dir = tempfile::TempDir::new().unwrap();
1034 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1035 let cfg = RepoConfigV2 {
1036 version: "2.0".to_string(),
1037 mount_dirs: MountDirsV2 {
1038 thoughts: "same".to_string(),
1039 context: "same".to_string(),
1040 references: "references".to_string(),
1041 },
1042 thoughts_mount: None,
1043 context_mounts: vec![],
1044 references: vec![],
1045 };
1046
1047 let result = mgr.save_v2_validated(&cfg);
1048 assert!(result.is_err());
1049
1050 let config_path = paths::get_repo_config_path(temp_dir.path());
1052 assert!(!config_path.exists());
1053 }
1054
1055 #[test]
1056 fn test_save_v2_validated_returns_warnings_on_valid() {
1057 let temp_dir = tempfile::TempDir::new().unwrap();
1058 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1059 let cfg = RepoConfigV2 {
1060 version: "2.0".to_string(),
1061 mount_dirs: MountDirsV2::default(),
1062 thoughts_mount: None,
1063 context_mounts: vec![ContextMount {
1064 remote: "git@github.com:org/repo.git".to_string(),
1065 subpath: None,
1066 mount_path: "mount1".to_string(),
1067 sync: SyncStrategy::None,
1068 }],
1069 references: vec![],
1070 };
1071
1072 let result = mgr.save_v2_validated(&cfg);
1073 assert!(result.is_ok());
1074 let warnings = result.unwrap();
1075 assert_eq!(warnings.len(), 1);
1076 assert!(warnings[0].contains("sync:None"));
1077
1078 let config_path = paths::get_repo_config_path(temp_dir.path());
1080 assert!(config_path.exists());
1081 }
1082
1083 #[test]
1084 fn test_ensure_v2_default_rejects_v1() {
1085 let temp_dir = tempfile::TempDir::new().unwrap();
1086 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1087
1088 let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
1090 let config_path = paths::get_repo_config_path(temp_dir.path());
1091 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1092 std::fs::write(&config_path, v1_json).unwrap();
1093
1094 let result = mgr.ensure_v2_default();
1096 assert!(result.is_err());
1097 assert!(result.unwrap_err().to_string().contains("v1"));
1098 }
1099
1100 #[test]
1101 fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1102 let temp_dir = tempfile::TempDir::new().unwrap();
1103 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1104 let cfg = RepoConfigV2 {
1105 version: "2.0".to_string(),
1106 mount_dirs: MountDirsV2::default(),
1107 thoughts_mount: None,
1108 context_mounts: vec![ContextMount {
1109 remote: "git@github.com:org/repo.git".to_string(),
1110 subpath: None,
1111 mount_path: " ".to_string(), sync: SyncStrategy::Auto,
1113 }],
1114 references: vec![],
1115 };
1116 let result = mgr.validate_v2_hard(&cfg);
1117 assert!(result.is_err());
1118 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1119 }
1120
1121 #[test]
1122 fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1123 let temp_dir = tempfile::TempDir::new().unwrap();
1124 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1125 let cfg = RepoConfigV2 {
1126 version: "2.0".to_string(),
1127 mount_dirs: MountDirsV2::default(),
1128 thoughts_mount: None,
1129 context_mounts: vec![ContextMount {
1130 remote: "git@github.com:org/repo.git".to_string(),
1131 subpath: None,
1132 mount_path: ".".to_string(),
1133 sync: SyncStrategy::Auto,
1134 }],
1135 references: vec![],
1136 };
1137 let result = mgr.validate_v2_hard(&cfg);
1138 assert!(result.is_err());
1139 assert!(
1140 result
1141 .unwrap_err()
1142 .to_string()
1143 .contains("cannot be '.' or '..'")
1144 );
1145 }
1146
1147 #[test]
1148 fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1149 let temp_dir = tempfile::TempDir::new().unwrap();
1150 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1151 let cfg = RepoConfigV2 {
1152 version: "2.0".to_string(),
1153 mount_dirs: MountDirsV2::default(),
1154 thoughts_mount: None,
1155 context_mounts: vec![ContextMount {
1156 remote: "git@github.com:org/repo.git".to_string(),
1157 subpath: None,
1158 mount_path: "..".to_string(),
1159 sync: SyncStrategy::Auto,
1160 }],
1161 references: vec![],
1162 };
1163 let result = mgr.validate_v2_hard(&cfg);
1164 assert!(result.is_err());
1165 assert!(
1166 result
1167 .unwrap_err()
1168 .to_string()
1169 .contains("cannot be '.' or '..'")
1170 );
1171 }
1172
1173 #[test]
1174 fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1175 let temp_dir = tempfile::TempDir::new().unwrap();
1176 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1177 let cfg = RepoConfigV2 {
1178 version: "2.0".to_string(),
1179 mount_dirs: MountDirsV2::default(),
1180 thoughts_mount: None,
1181 context_mounts: vec![ContextMount {
1182 remote: "git@github.com:org/repo.git".to_string(),
1183 subpath: None,
1184 mount_path: "sub/path".to_string(),
1185 sync: SyncStrategy::Auto,
1186 }],
1187 references: vec![],
1188 };
1189 let result = mgr.validate_v2_hard(&cfg);
1190 assert!(result.is_err());
1191 assert!(
1192 result
1193 .unwrap_err()
1194 .to_string()
1195 .contains("single path segment")
1196 );
1197 }
1198
1199 #[test]
1200 fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1201 let temp_dir = tempfile::TempDir::new().unwrap();
1202 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1203 let cfg = RepoConfigV2 {
1204 version: "2.0".to_string(),
1205 mount_dirs: MountDirsV2::default(),
1206 thoughts_mount: None,
1207 context_mounts: vec![ContextMount {
1208 remote: "git@github.com:org/repo.git".to_string(),
1209 subpath: None,
1210 mount_path: "sub\\path".to_string(),
1211 sync: SyncStrategy::Auto,
1212 }],
1213 references: vec![],
1214 };
1215 let result = mgr.validate_v2_hard(&cfg);
1216 assert!(result.is_err());
1217 assert!(
1218 result
1219 .unwrap_err()
1220 .to_string()
1221 .contains("single path segment")
1222 );
1223 }
1224
1225 #[test]
1226 fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1227 let temp_dir = tempfile::TempDir::new().unwrap();
1228 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1229 let cfg = RepoConfigV2 {
1230 version: "2.0".to_string(),
1231 mount_dirs: MountDirsV2::default(),
1232 thoughts_mount: None,
1233 context_mounts: vec![ContextMount {
1234 remote: "git@github.com:org/repo.git".to_string(),
1235 subpath: None,
1236 mount_path: "docs".to_string(),
1237 sync: SyncStrategy::Auto,
1238 }],
1239 references: vec![],
1240 };
1241 let result = mgr.validate_v2_hard(&cfg);
1242 assert!(result.is_ok());
1243 let warnings = result.unwrap();
1244 assert_eq!(warnings.len(), 0);
1245 }
1246
1247 #[test]
1248 fn test_new_makes_absolute_when_given_relative_repo_root() {
1249 let temp_dir = TempDir::new().unwrap();
1250 let cwd_before = std::env::current_dir().unwrap();
1251
1252 std::env::set_current_dir(temp_dir.path()).unwrap();
1254
1255 std::fs::create_dir_all("repo").unwrap();
1257
1258 let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1259
1260 assert!(mgr.peek_config_version().is_ok());
1264
1265 std::env::set_current_dir(cwd_before).unwrap();
1267 }
1268
1269 #[test]
1276 fn test_validate_v2_hard_rejects_trailing_slash_in_mount_dirs() {
1277 let temp_dir = tempfile::TempDir::new().unwrap();
1278 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1279
1280 let cfg = RepoConfigV2 {
1282 version: "2.0".to_string(),
1283 mount_dirs: MountDirsV2 {
1284 thoughts: "thoughts/".to_string(),
1285 context: "context".to_string(),
1286 references: "references".to_string(),
1287 },
1288 thoughts_mount: None,
1289 context_mounts: vec![],
1290 references: vec![],
1291 };
1292 let result = mgr.validate_v2_hard(&cfg);
1293 assert!(
1294 result.is_err(),
1295 "trailing slash on thoughts should be rejected"
1296 );
1297 assert!(
1298 result
1299 .unwrap_err()
1300 .to_string()
1301 .contains("single path segment"),
1302 "error should mention single path segment requirement"
1303 );
1304
1305 let cfg = RepoConfigV2 {
1307 version: "2.0".to_string(),
1308 mount_dirs: MountDirsV2 {
1309 thoughts: "thoughts".to_string(),
1310 context: "context/".to_string(),
1311 references: "references".to_string(),
1312 },
1313 thoughts_mount: None,
1314 context_mounts: vec![],
1315 references: vec![],
1316 };
1317 let result = mgr.validate_v2_hard(&cfg);
1318 assert!(
1319 result.is_err(),
1320 "trailing slash on context should be rejected"
1321 );
1322
1323 let cfg = RepoConfigV2 {
1325 version: "2.0".to_string(),
1326 mount_dirs: MountDirsV2 {
1327 thoughts: "thoughts".to_string(),
1328 context: "context".to_string(),
1329 references: "references/".to_string(),
1330 },
1331 thoughts_mount: None,
1332 context_mounts: vec![],
1333 references: vec![],
1334 };
1335 let result = mgr.validate_v2_hard(&cfg);
1336 assert!(
1337 result.is_err(),
1338 "trailing slash on references should be rejected"
1339 );
1340 }
1341}