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 pub fn new(repo_root: PathBuf) -> Self {
73 let abs = if repo_root.is_absolute() {
75 repo_root
76 } else {
77 std::fs::canonicalize(&repo_root).unwrap_or_else(|_| {
78 std::env::current_dir()
79 .expect("Failed to determine current directory for path normalization")
80 .join(&repo_root)
81 })
82 };
83 Self { repo_root: abs }
84 }
85
86 pub fn load_desired_state(&self) -> Result<Option<DesiredState>> {
87 let config_path = paths::get_repo_config_path(&self.repo_root);
88 if !config_path.exists() {
89 return Ok(None);
90 }
91
92 let raw = std::fs::read_to_string(&config_path)?;
93 let v: serde_json::Value = serde_json::from_str(&raw)?;
95 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
96
97 if version == "2.0" {
98 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
99 let refs = v2
101 .references
102 .into_iter()
103 .map(|e| match e {
104 ReferenceEntry::Simple(url) => ReferenceMount {
105 remote: url,
106 description: None,
107 ref_name: None,
108 },
109 ReferenceEntry::WithMetadata(rm) => rm,
110 })
111 .collect();
112 return Ok(Some(DesiredState {
113 mount_dirs: v2.mount_dirs,
114 thoughts_mount: v2.thoughts_mount,
115 context_mounts: v2.context_mounts,
116 references: refs,
117 was_v1: false,
118 }));
119 }
120
121 anyhow::bail!(
123 "Unsupported legacy config version (v1). V1 configurations are no longer supported. \
124 Please upgrade to a v2 configuration format."
125 );
126 }
127
128 fn validate_remote(&self, remote: &str) -> Result<()> {
129 if remote.starts_with("./") {
130 return Ok(());
132 }
133
134 if !remote.starts_with("git@")
135 && !remote.starts_with("https://")
136 && !remote.starts_with("ssh://")
137 {
138 anyhow::bail!(
139 "Invalid remote URL: {}. Must be a git URL or relative path starting with ./",
140 remote
141 );
142 }
143
144 Ok(())
145 }
146
147 pub fn load_v2_or_bail(&self) -> Result<RepoConfigV2> {
149 let config_path = paths::get_repo_config_path(&self.repo_root);
150 if !config_path.exists() {
151 anyhow::bail!("No repository configuration found. Run 'thoughts init' first.");
152 }
153
154 let raw = std::fs::read_to_string(&config_path)?;
155 let v: serde_json::Value = serde_json::from_str(&raw)?;
156 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
157
158 if version == "2.0" {
159 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
160 Ok(v2)
161 } else {
162 anyhow::bail!(
163 "Repository is using v1 configuration. Please migrate to v2 configuration format."
164 );
165 }
166 }
167
168 pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
170 let config_path = paths::get_repo_config_path(&self.repo_root);
171
172 if let Some(parent) = config_path.parent() {
174 fs::create_dir_all(parent)
175 .with_context(|| format!("Failed to create directory {parent:?}"))?;
176 }
177
178 let json =
179 serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
180
181 AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
182 .write(|f| f.write_all(json.as_bytes()))
183 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
184
185 Ok(())
186 }
187
188 pub fn ensure_v2_default(&self) -> Result<RepoConfigV2> {
191 let config_path = paths::get_repo_config_path(&self.repo_root);
192 if config_path.exists() {
193 let raw = std::fs::read_to_string(&config_path)?;
195 let v: serde_json::Value = serde_json::from_str(&raw)?;
196 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
197
198 if version == "2.0" {
199 return serde_json::from_str(&raw).context("Failed to parse v2 configuration");
200 }
201
202 anyhow::bail!(
204 "Unsupported legacy config version (v1). V1 configurations are no longer supported. \
205 Please manually migrate to v2 format or delete the config and reinitialize."
206 );
207 }
208
209 let default_config = RepoConfigV2 {
211 version: "2.0".to_string(),
212 mount_dirs: MountDirsV2::default(),
213 thoughts_mount: None,
214 context_mounts: vec![],
215 references: vec![],
216 };
217
218 self.save_v2(&default_config)?;
219 Ok(default_config)
220 }
221
222 pub fn validate_v2_soft(&self, cfg: &RepoConfigV2) -> Vec<String> {
224 let mut warnings = Vec::new();
225 for r in &cfg.references {
226 let url = match r {
227 ReferenceEntry::Simple(s) => s.as_str(),
228 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
229 };
230 if let Err(e) = crate::config::validation::validate_reference_url(url) {
231 warnings.push(format!("Invalid reference '{}': {}", url, e));
232 }
233 }
234 warnings
235 }
236
237 pub fn peek_config_version(&self) -> Result<Option<String>> {
239 let config_path = paths::get_repo_config_path(&self.repo_root);
240 if !config_path.exists() {
241 return Ok(None);
242 }
243 let raw = std::fs::read_to_string(&config_path)?;
244 let v: serde_json::Value = serde_json::from_str(&raw)?;
245 Ok(v.get("version")
246 .and_then(|x| x.as_str())
247 .map(|s| s.to_string()))
248 }
249
250 pub fn validate_v2_hard(&self, cfg: &RepoConfigV2) -> Result<Vec<String>> {
252 if cfg.version != "2.0" {
253 anyhow::bail!("Unsupported configuration version: {}", cfg.version);
254 }
255
256 let m = &cfg.mount_dirs;
258 for (name, val) in [
259 ("thoughts", &m.thoughts),
260 ("context", &m.context),
261 ("references", &m.references),
262 ] {
263 if val.trim().is_empty() {
264 anyhow::bail!("Mount directory '{}' cannot be empty", name);
265 }
266 if val == ".thoughts-data" {
267 anyhow::bail!(
268 "Mount directory '{}' cannot be named '.thoughts-data'",
269 name
270 );
271 }
272 if val == "." || val == ".." {
273 anyhow::bail!("Mount directory '{}' cannot be '.' or '..'", name);
274 }
275 if val.contains('/') || val.contains('\\') {
276 anyhow::bail!(
277 "Mount directory '{}' must be a single path segment (got {})",
278 name,
279 val
280 );
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 use crate::config::validation::canonical_reference_instance_key;
338 use crate::config::validation::validate_pinned_ref_full_name;
339 use crate::config::validation::validate_reference_url;
340 let mut seen_refs = std::collections::HashSet::new();
341 for r in &cfg.references {
342 let (url, ref_name) = match r {
343 ReferenceEntry::Simple(s) => (s.as_str(), None),
344 ReferenceEntry::WithMetadata(rm) => (rm.remote.as_str(), rm.ref_name.as_deref()),
345 };
346 validate_reference_url(url).with_context(|| format!("Invalid reference '{}'", url))?;
347 if let Some(ref_name) = ref_name {
348 if ref_name.starts_with("refs/remotes/") {
349 warnings.push(format!(
350 "Reference '{}' uses legacy pinned ref '{}'. New references should use refs/heads/* or refs/tags/*; refs/remotes/* is a local remote-tracking namespace.",
351 url, ref_name
352 ));
353 }
354 validate_pinned_ref_full_name(ref_name).with_context(|| {
355 format!("Invalid pinned ref '{}' for reference '{}'", ref_name, url)
356 })?;
357 }
358 let key = canonical_reference_instance_key(url, ref_name)?;
359 if !seen_refs.insert(key) {
360 anyhow::bail!("Duplicate reference detected: {}", url);
361 }
362 }
363
364 Ok(warnings)
365 }
366
367 pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
369 let warnings = self.validate_v2_hard(config)?;
370 self.save_v2(config)?;
371 Ok(warnings)
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use crate::utils::paths;
379 use tempfile::TempDir;
380
381 #[test]
382 fn test_load_desired_state_rejects_v1() {
383 let temp_dir = TempDir::new().unwrap();
384 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
385
386 let v1_json = r#"{
388 "version": "1.0",
389 "mount_dirs": {"repository": "context", "personal": "personal"},
390 "requires": [],
391 "rules": []
392 }"#;
393
394 let config_path = paths::get_repo_config_path(temp_dir.path());
395 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
396 std::fs::write(&config_path, v1_json).unwrap();
397
398 let result = manager.load_desired_state();
400 assert!(result.is_err());
401 assert!(result.unwrap_err().to_string().contains("v1"));
402 }
403
404 #[test]
405 fn test_v2_config_loading() {
406 let temp_dir = TempDir::new().unwrap();
407 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
408
409 let v2_config = crate::config::RepoConfigV2 {
411 version: "2.0".to_string(),
412 mount_dirs: crate::config::MountDirsV2::default(),
413 thoughts_mount: Some(crate::config::ThoughtsMount {
414 remote: "git@github.com:user/thoughts.git".to_string(),
415 subpath: None,
416 sync: crate::config::SyncStrategy::Auto,
417 }),
418 context_mounts: vec![crate::config::ContextMount {
419 remote: "git@github.com:user/context.git".to_string(),
420 subpath: Some("docs".to_string()),
421 mount_path: "docs".to_string(),
422 sync: crate::config::SyncStrategy::Auto,
423 }],
424 references: vec![
425 ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
426 ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
427 ],
428 };
429
430 let config_path = paths::get_repo_config_path(temp_dir.path());
432 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
433 let json = serde_json::to_string_pretty(&v2_config).unwrap();
434 std::fs::write(&config_path, json).unwrap();
435
436 let desired_state = manager.load_desired_state().unwrap().unwrap();
438
439 assert!(!desired_state.was_v1);
441 assert!(desired_state.thoughts_mount.is_some());
442 assert_eq!(
443 desired_state.thoughts_mount.as_ref().unwrap().remote,
444 "git@github.com:user/thoughts.git"
445 );
446 assert_eq!(desired_state.context_mounts.len(), 1);
447 assert_eq!(desired_state.references.len(), 2);
448 }
449
450 #[test]
451 fn test_v2_references_normalize_to_reference_mount() {
452 let temp_dir = TempDir::new().unwrap();
453 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
454
455 let json = r#"{
456 "version": "2.0",
457 "mount_dirs": {},
458 "context_mounts": [],
459 "references": [
460 "git@github.com:org/ref1.git",
461 {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
462 ]
463 }"#;
464
465 let config_path = paths::get_repo_config_path(temp_dir.path());
466 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
467 std::fs::write(&config_path, json).unwrap();
468
469 let ds = manager.load_desired_state().unwrap().unwrap();
470 assert_eq!(ds.references.len(), 2);
471 assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
472 assert_eq!(ds.references[0].description, None);
473 assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
474 assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
475 }
476
477 #[test]
478 fn test_validate_v2_soft_handles_both_variants() {
479 let temp_dir = tempfile::TempDir::new().unwrap();
480 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
481
482 let cfg = RepoConfigV2 {
483 version: "2.0".into(),
484 mount_dirs: MountDirsV2::default(),
485 thoughts_mount: None,
486 context_mounts: vec![],
487 references: vec![
488 ReferenceEntry::Simple("https://github.com/org/repo".into()),
489 ReferenceEntry::WithMetadata(ReferenceMount {
490 remote: "git@github.com:org/repo.git:docs".into(), description: None,
492 ref_name: None,
493 }),
494 ],
495 };
496
497 let warnings = mgr.validate_v2_soft(&cfg);
498 assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
499 assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
500 }
501
502 #[test]
503 fn test_peek_config_version_returns_none_when_no_config() {
504 let temp_dir = tempfile::TempDir::new().unwrap();
505 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
506 assert_eq!(mgr.peek_config_version().unwrap(), None);
507 }
508
509 #[test]
510 fn test_peek_config_version_returns_v1() {
511 let temp_dir = tempfile::TempDir::new().unwrap();
512 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
513
514 let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
516 let config_path = paths::get_repo_config_path(temp_dir.path());
517 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
518 std::fs::write(&config_path, v1_json).unwrap();
519
520 assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
521 }
522
523 #[test]
524 fn test_peek_config_version_returns_v2() {
525 let temp_dir = tempfile::TempDir::new().unwrap();
526 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
527 let v2_config = RepoConfigV2 {
528 version: "2.0".to_string(),
529 mount_dirs: MountDirsV2::default(),
530 thoughts_mount: None,
531 context_mounts: vec![],
532 references: vec![],
533 };
534 mgr.save_v2(&v2_config).unwrap();
535 assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
536 }
537
538 #[test]
539 fn test_validate_v2_hard_rejects_invalid_version() {
540 let temp_dir = tempfile::TempDir::new().unwrap();
541 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
542 let cfg = RepoConfigV2 {
543 version: "3.0".to_string(),
544 mount_dirs: MountDirsV2::default(),
545 thoughts_mount: None,
546 context_mounts: vec![],
547 references: vec![],
548 };
549 let result = mgr.validate_v2_hard(&cfg);
550 assert!(result.is_err());
551 assert!(
552 result
553 .unwrap_err()
554 .to_string()
555 .contains("Unsupported configuration version: 3.0")
556 );
557 }
558
559 #[test]
560 fn test_validate_v2_hard_rejects_pinned_ref_with_whitespace_or_trailing_slash() {
561 let temp_dir = tempfile::TempDir::new().unwrap();
562 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
563
564 for bad_ref in [" refs/heads/main", "refs/heads/main ", "refs/heads/main/"] {
565 let cfg = RepoConfigV2 {
566 version: "2.0".to_string(),
567 mount_dirs: MountDirsV2::default(),
568 thoughts_mount: None,
569 context_mounts: vec![],
570 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
571 remote: "https://github.com/org/repo".to_string(),
572 description: None,
573 ref_name: Some(bad_ref.to_string()),
574 })],
575 };
576
577 assert!(
578 mgr.validate_v2_hard(&cfg).is_err(),
579 "expected {bad_ref:?} to fail hard validation"
580 );
581 }
582 }
583
584 #[test]
585 fn test_validate_v2_hard_rejects_empty_mount_dirs() {
586 let temp_dir = tempfile::TempDir::new().unwrap();
587 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
588 let cfg = RepoConfigV2 {
589 version: "2.0".to_string(),
590 mount_dirs: MountDirsV2 {
591 thoughts: "".to_string(),
592 context: "context".to_string(),
593 references: "references".to_string(),
594 },
595 thoughts_mount: None,
596 context_mounts: vec![],
597 references: vec![],
598 };
599 let result = mgr.validate_v2_hard(&cfg);
600 assert!(result.is_err());
601 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
602 }
603
604 #[test]
605 fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
606 let temp_dir = tempfile::TempDir::new().unwrap();
607 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
608 let cfg = RepoConfigV2 {
609 version: "2.0".to_string(),
610 mount_dirs: MountDirsV2 {
611 thoughts: ".thoughts-data".to_string(),
612 context: "context".to_string(),
613 references: "references".to_string(),
614 },
615 thoughts_mount: None,
616 context_mounts: vec![],
617 references: vec![],
618 };
619 let result = mgr.validate_v2_hard(&cfg);
620 assert!(result.is_err());
621 assert!(result.unwrap_err().to_string().contains(".thoughts-data"));
622 }
623
624 #[test]
625 fn test_validate_v2_hard_rejects_dot_mount_dirs() {
626 let temp_dir = tempfile::TempDir::new().unwrap();
627 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
628 let cfg = RepoConfigV2 {
629 version: "2.0".to_string(),
630 mount_dirs: MountDirsV2 {
631 thoughts: ".".to_string(),
632 context: "context".to_string(),
633 references: "references".to_string(),
634 },
635 thoughts_mount: None,
636 context_mounts: vec![],
637 references: vec![],
638 };
639 let result = mgr.validate_v2_hard(&cfg);
640 assert!(result.is_err());
641 assert!(
642 result
643 .unwrap_err()
644 .to_string()
645 .contains("cannot be '.' or '..'")
646 );
647 }
648
649 #[test]
650 fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
651 let temp_dir = tempfile::TempDir::new().unwrap();
652 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
653 let cfg = RepoConfigV2 {
654 version: "2.0".to_string(),
655 mount_dirs: MountDirsV2 {
656 thoughts: "sub/path".to_string(),
657 context: "context".to_string(),
658 references: "references".to_string(),
659 },
660 thoughts_mount: None,
661 context_mounts: vec![],
662 references: vec![],
663 };
664 let result = mgr.validate_v2_hard(&cfg);
665 assert!(result.is_err());
666 assert!(
667 result
668 .unwrap_err()
669 .to_string()
670 .contains("must be a single path segment")
671 );
672 }
673
674 #[test]
675 fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
676 let temp_dir = tempfile::TempDir::new().unwrap();
677 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
678 let cfg = RepoConfigV2 {
679 version: "2.0".to_string(),
680 mount_dirs: MountDirsV2 {
681 thoughts: "same".to_string(),
682 context: "same".to_string(),
683 references: "references".to_string(),
684 },
685 thoughts_mount: None,
686 context_mounts: vec![],
687 references: vec![],
688 };
689 let result = mgr.validate_v2_hard(&cfg);
690 assert!(result.is_err());
691 assert!(result.unwrap_err().to_string().contains("must be distinct"));
692 }
693
694 #[test]
695 fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
696 let temp_dir = tempfile::TempDir::new().unwrap();
697 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
698 let cfg = RepoConfigV2 {
699 version: "2.0".to_string(),
700 mount_dirs: MountDirsV2::default(),
701 thoughts_mount: Some(ThoughtsMount {
702 remote: "invalid-url".to_string(),
703 subpath: None,
704 sync: SyncStrategy::Auto,
705 }),
706 context_mounts: vec![],
707 references: vec![],
708 };
709 let result = mgr.validate_v2_hard(&cfg);
710 assert!(result.is_err());
711 assert!(
712 result
713 .unwrap_err()
714 .to_string()
715 .contains("Invalid remote URL")
716 );
717 }
718
719 #[test]
720 fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
721 let temp_dir = tempfile::TempDir::new().unwrap();
722 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
723 let cfg = RepoConfigV2 {
724 version: "2.0".to_string(),
725 mount_dirs: MountDirsV2::default(),
726 thoughts_mount: None,
727 context_mounts: vec![
728 ContextMount {
729 remote: "git@github.com:org/repo1.git".to_string(),
730 subpath: None,
731 mount_path: "same".to_string(),
732 sync: SyncStrategy::Auto,
733 },
734 ContextMount {
735 remote: "git@github.com:org/repo2.git".to_string(),
736 subpath: None,
737 mount_path: "same".to_string(),
738 sync: SyncStrategy::Auto,
739 },
740 ],
741 references: vec![],
742 };
743 let result = mgr.validate_v2_hard(&cfg);
744 assert!(result.is_err());
745 assert!(
746 result
747 .unwrap_err()
748 .to_string()
749 .contains("Duplicate context mount path")
750 );
751 }
752
753 #[test]
754 fn test_validate_v2_hard_rejects_invalid_context_remote() {
755 let temp_dir = tempfile::TempDir::new().unwrap();
756 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
757 let cfg = RepoConfigV2 {
758 version: "2.0".to_string(),
759 mount_dirs: MountDirsV2::default(),
760 thoughts_mount: None,
761 context_mounts: vec![ContextMount {
762 remote: "invalid-url".to_string(),
763 subpath: None,
764 mount_path: "mount1".to_string(),
765 sync: SyncStrategy::Auto,
766 }],
767 references: vec![],
768 };
769 let result = mgr.validate_v2_hard(&cfg);
770 assert!(result.is_err());
771 assert!(
772 result
773 .unwrap_err()
774 .to_string()
775 .contains("Invalid remote URL")
776 );
777 }
778
779 #[test]
780 fn test_validate_v2_hard_warns_on_sync_none() {
781 let temp_dir = tempfile::TempDir::new().unwrap();
782 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
783 let cfg = RepoConfigV2 {
784 version: "2.0".to_string(),
785 mount_dirs: MountDirsV2::default(),
786 thoughts_mount: None,
787 context_mounts: vec![ContextMount {
788 remote: "git@github.com:org/repo.git".to_string(),
789 subpath: None,
790 mount_path: "mount1".to_string(),
791 sync: SyncStrategy::None,
792 }],
793 references: vec![],
794 };
795 let result = mgr.validate_v2_hard(&cfg);
796 assert!(result.is_ok());
797 let warnings = result.unwrap();
798 assert_eq!(warnings.len(), 1);
799 assert!(warnings[0].contains("sync:None"));
800 assert!(warnings[0].contains("discouraged"));
801 }
802
803 #[test]
804 fn test_validate_v2_hard_rejects_invalid_reference_url() {
805 let temp_dir = tempfile::TempDir::new().unwrap();
806 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
807 let cfg = RepoConfigV2 {
808 version: "2.0".to_string(),
809 mount_dirs: MountDirsV2::default(),
810 thoughts_mount: None,
811 context_mounts: vec![],
812 references: vec![ReferenceEntry::Simple(
813 "git@github.com:org/repo.git:subpath".to_string(),
814 )],
815 };
816 let result = mgr.validate_v2_hard(&cfg);
817 assert!(result.is_err());
818 assert!(result.unwrap_err().to_string().contains("subpath"));
819 }
820
821 #[test]
822 fn test_validate_v2_hard_rejects_duplicate_references() {
823 let temp_dir = tempfile::TempDir::new().unwrap();
824 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
825 let cfg = RepoConfigV2 {
826 version: "2.0".to_string(),
827 mount_dirs: MountDirsV2::default(),
828 thoughts_mount: None,
829 context_mounts: vec![],
830 references: vec![
831 ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
832 ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
833 ],
834 };
835 let result = mgr.validate_v2_hard(&cfg);
836 assert!(result.is_err());
837 assert!(
838 result
839 .unwrap_err()
840 .to_string()
841 .contains("Duplicate reference")
842 );
843 }
844
845 #[test]
846 fn test_validate_v2_hard_allows_same_repo_with_different_refs() {
847 let temp_dir = tempfile::TempDir::new().unwrap();
848 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
849 let cfg = RepoConfigV2 {
850 version: "2.0".to_string(),
851 mount_dirs: MountDirsV2::default(),
852 thoughts_mount: None,
853 context_mounts: vec![],
854 references: vec![
855 ReferenceEntry::WithMetadata(ReferenceMount {
856 remote: "https://github.com/org/repo".to_string(),
857 description: None,
858 ref_name: Some("refs/heads/main".to_string()),
859 }),
860 ReferenceEntry::WithMetadata(ReferenceMount {
861 remote: "git@github.com:Org/Repo.git".to_string(),
862 description: None,
863 ref_name: Some("refs/tags/v1.0.0".to_string()),
864 }),
865 ],
866 };
867
868 let result = mgr.validate_v2_hard(&cfg);
869 assert!(result.is_ok());
870 }
871
872 #[test]
873 fn test_validate_v2_hard_rejects_duplicate_references_with_same_ref() {
874 let temp_dir = tempfile::TempDir::new().unwrap();
875 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
876 let cfg = RepoConfigV2 {
877 version: "2.0".to_string(),
878 mount_dirs: MountDirsV2::default(),
879 thoughts_mount: None,
880 context_mounts: vec![],
881 references: vec![
882 ReferenceEntry::WithMetadata(ReferenceMount {
883 remote: "https://github.com/org/repo".to_string(),
884 description: None,
885 ref_name: Some("refs/heads/main".to_string()),
886 }),
887 ReferenceEntry::WithMetadata(ReferenceMount {
888 remote: "git@github.com:Org/Repo.git".to_string(),
889 description: Some("duplicate".to_string()),
890 ref_name: Some("refs/heads/main".to_string()),
891 }),
892 ],
893 };
894
895 let result = mgr.validate_v2_hard(&cfg);
896 assert!(result.is_err());
897 assert!(
898 result
899 .unwrap_err()
900 .to_string()
901 .contains("Duplicate reference")
902 );
903 }
904
905 #[test]
906 fn test_validate_v2_hard_rejects_duplicate_references_legacy_remotes_vs_heads() {
907 let temp_dir = tempfile::TempDir::new().unwrap();
908 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
909
910 let cfg = RepoConfigV2 {
911 version: "2.0".to_string(),
912 mount_dirs: MountDirsV2::default(),
913 thoughts_mount: None,
914 context_mounts: vec![],
915 references: vec![
916 ReferenceEntry::WithMetadata(ReferenceMount {
917 remote: "https://github.com/org/repo".to_string(),
918 description: None,
919 ref_name: Some("refs/remotes/origin/main".to_string()),
920 }),
921 ReferenceEntry::WithMetadata(ReferenceMount {
922 remote: "git@github.com:Org/Repo.git".to_string(),
923 description: None,
924 ref_name: Some("refs/heads/main".to_string()),
925 }),
926 ],
927 };
928
929 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
930 assert!(err.to_string().contains("Duplicate reference"));
931 }
932
933 #[test]
934 fn test_validate_v2_hard_rejects_shorthand_pinned_ref() {
935 let temp_dir = tempfile::TempDir::new().unwrap();
936 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
937
938 let cfg = RepoConfigV2 {
939 version: "2.0".to_string(),
940 mount_dirs: MountDirsV2::default(),
941 thoughts_mount: None,
942 context_mounts: vec![],
943 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
944 remote: "https://github.com/org/repo".to_string(),
945 description: None,
946 ref_name: Some("main".to_string()),
947 })],
948 };
949
950 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
951 assert!(
952 format!("{err:#}").contains("Pinned refs must be full ref names"),
953 "unexpected error chain: {err:#}"
954 );
955 }
956
957 #[test]
958 fn test_validate_v2_hard_rejects_incomplete_pinned_ref_prefix() {
959 let temp_dir = tempfile::TempDir::new().unwrap();
960 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
961
962 let cfg = RepoConfigV2 {
963 version: "2.0".to_string(),
964 mount_dirs: MountDirsV2::default(),
965 thoughts_mount: None,
966 context_mounts: vec![],
967 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
968 remote: "https://github.com/org/repo".to_string(),
969 description: None,
970 ref_name: Some("refs/heads/".to_string()),
971 })],
972 };
973
974 assert!(mgr.validate_v2_hard(&cfg).is_err());
975 }
976
977 #[test]
978 fn test_validate_v2_hard_warns_on_legacy_refs_remotes() {
979 let temp_dir = tempfile::TempDir::new().unwrap();
980 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
981
982 let cfg = RepoConfigV2 {
983 version: "2.0".to_string(),
984 mount_dirs: MountDirsV2::default(),
985 thoughts_mount: None,
986 context_mounts: vec![],
987 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
988 remote: "https://github.com/org/repo".to_string(),
989 description: None,
990 ref_name: Some("refs/remotes/origin/main".to_string()),
991 })],
992 };
993
994 let warnings = mgr
995 .validate_v2_hard(&cfg)
996 .expect("legacy refs/remotes should warn");
997 assert_eq!(warnings.len(), 1);
998 assert!(warnings[0].contains("refs/remotes/origin/main"));
999 assert!(warnings[0].contains("legacy pinned ref"));
1000 }
1001
1002 #[test]
1003 fn test_validate_v2_hard_accepts_valid_config() {
1004 let temp_dir = tempfile::TempDir::new().unwrap();
1005 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1006 let cfg = RepoConfigV2 {
1007 version: "2.0".to_string(),
1008 mount_dirs: MountDirsV2::default(),
1009 thoughts_mount: Some(ThoughtsMount {
1010 remote: "git@github.com:user/thoughts.git".to_string(),
1011 subpath: None,
1012 sync: SyncStrategy::Auto,
1013 }),
1014 context_mounts: vec![ContextMount {
1015 remote: "git@github.com:org/context.git".to_string(),
1016 subpath: Some("docs".to_string()),
1017 mount_path: "docs".to_string(),
1018 sync: SyncStrategy::Auto,
1019 }],
1020 references: vec![
1021 ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1022 ReferenceEntry::WithMetadata(ReferenceMount {
1023 remote: "https://github.com/org/repo2".to_string(),
1024 description: Some("Reference 2".to_string()),
1025 ref_name: None,
1026 }),
1027 ],
1028 };
1029 let result = mgr.validate_v2_hard(&cfg);
1030 assert!(result.is_ok());
1031 let warnings = result.unwrap();
1032 assert_eq!(warnings.len(), 0);
1033 }
1034
1035 #[test]
1036 fn test_save_v2_validated_fails_before_write_on_invalid() {
1037 let temp_dir = tempfile::TempDir::new().unwrap();
1038 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1039 let cfg = RepoConfigV2 {
1040 version: "2.0".to_string(),
1041 mount_dirs: MountDirsV2 {
1042 thoughts: "same".to_string(),
1043 context: "same".to_string(),
1044 references: "references".to_string(),
1045 },
1046 thoughts_mount: None,
1047 context_mounts: vec![],
1048 references: vec![],
1049 };
1050
1051 let result = mgr.save_v2_validated(&cfg);
1052 assert!(result.is_err());
1053
1054 let config_path = paths::get_repo_config_path(temp_dir.path());
1056 assert!(!config_path.exists());
1057 }
1058
1059 #[test]
1060 fn test_save_v2_validated_returns_warnings_on_valid() {
1061 let temp_dir = tempfile::TempDir::new().unwrap();
1062 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1063 let cfg = RepoConfigV2 {
1064 version: "2.0".to_string(),
1065 mount_dirs: MountDirsV2::default(),
1066 thoughts_mount: None,
1067 context_mounts: vec![ContextMount {
1068 remote: "git@github.com:org/repo.git".to_string(),
1069 subpath: None,
1070 mount_path: "mount1".to_string(),
1071 sync: SyncStrategy::None,
1072 }],
1073 references: vec![],
1074 };
1075
1076 let result = mgr.save_v2_validated(&cfg);
1077 assert!(result.is_ok());
1078 let warnings = result.unwrap();
1079 assert_eq!(warnings.len(), 1);
1080 assert!(warnings[0].contains("sync:None"));
1081
1082 let config_path = paths::get_repo_config_path(temp_dir.path());
1084 assert!(config_path.exists());
1085 }
1086
1087 #[test]
1088 fn test_ensure_v2_default_rejects_v1() {
1089 let temp_dir = tempfile::TempDir::new().unwrap();
1090 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1091
1092 let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
1094 let config_path = paths::get_repo_config_path(temp_dir.path());
1095 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1096 std::fs::write(&config_path, v1_json).unwrap();
1097
1098 let result = mgr.ensure_v2_default();
1100 assert!(result.is_err());
1101 assert!(result.unwrap_err().to_string().contains("v1"));
1102 }
1103
1104 #[test]
1105 fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1106 let temp_dir = tempfile::TempDir::new().unwrap();
1107 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1108 let cfg = RepoConfigV2 {
1109 version: "2.0".to_string(),
1110 mount_dirs: MountDirsV2::default(),
1111 thoughts_mount: None,
1112 context_mounts: vec![ContextMount {
1113 remote: "git@github.com:org/repo.git".to_string(),
1114 subpath: None,
1115 mount_path: " ".to_string(), sync: SyncStrategy::Auto,
1117 }],
1118 references: vec![],
1119 };
1120 let result = mgr.validate_v2_hard(&cfg);
1121 assert!(result.is_err());
1122 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1123 }
1124
1125 #[test]
1126 fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1127 let temp_dir = tempfile::TempDir::new().unwrap();
1128 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1129 let cfg = RepoConfigV2 {
1130 version: "2.0".to_string(),
1131 mount_dirs: MountDirsV2::default(),
1132 thoughts_mount: None,
1133 context_mounts: vec![ContextMount {
1134 remote: "git@github.com:org/repo.git".to_string(),
1135 subpath: None,
1136 mount_path: ".".to_string(),
1137 sync: SyncStrategy::Auto,
1138 }],
1139 references: vec![],
1140 };
1141 let result = mgr.validate_v2_hard(&cfg);
1142 assert!(result.is_err());
1143 assert!(
1144 result
1145 .unwrap_err()
1146 .to_string()
1147 .contains("cannot be '.' or '..'")
1148 );
1149 }
1150
1151 #[test]
1152 fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1153 let temp_dir = tempfile::TempDir::new().unwrap();
1154 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1155 let cfg = RepoConfigV2 {
1156 version: "2.0".to_string(),
1157 mount_dirs: MountDirsV2::default(),
1158 thoughts_mount: None,
1159 context_mounts: vec![ContextMount {
1160 remote: "git@github.com:org/repo.git".to_string(),
1161 subpath: None,
1162 mount_path: "..".to_string(),
1163 sync: SyncStrategy::Auto,
1164 }],
1165 references: vec![],
1166 };
1167 let result = mgr.validate_v2_hard(&cfg);
1168 assert!(result.is_err());
1169 assert!(
1170 result
1171 .unwrap_err()
1172 .to_string()
1173 .contains("cannot be '.' or '..'")
1174 );
1175 }
1176
1177 #[test]
1178 fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1179 let temp_dir = tempfile::TempDir::new().unwrap();
1180 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1181 let cfg = RepoConfigV2 {
1182 version: "2.0".to_string(),
1183 mount_dirs: MountDirsV2::default(),
1184 thoughts_mount: None,
1185 context_mounts: vec![ContextMount {
1186 remote: "git@github.com:org/repo.git".to_string(),
1187 subpath: None,
1188 mount_path: "sub/path".to_string(),
1189 sync: SyncStrategy::Auto,
1190 }],
1191 references: vec![],
1192 };
1193 let result = mgr.validate_v2_hard(&cfg);
1194 assert!(result.is_err());
1195 assert!(
1196 result
1197 .unwrap_err()
1198 .to_string()
1199 .contains("single path segment")
1200 );
1201 }
1202
1203 #[test]
1204 fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1205 let temp_dir = tempfile::TempDir::new().unwrap();
1206 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1207 let cfg = RepoConfigV2 {
1208 version: "2.0".to_string(),
1209 mount_dirs: MountDirsV2::default(),
1210 thoughts_mount: None,
1211 context_mounts: vec![ContextMount {
1212 remote: "git@github.com:org/repo.git".to_string(),
1213 subpath: None,
1214 mount_path: "sub\\path".to_string(),
1215 sync: SyncStrategy::Auto,
1216 }],
1217 references: vec![],
1218 };
1219 let result = mgr.validate_v2_hard(&cfg);
1220 assert!(result.is_err());
1221 assert!(
1222 result
1223 .unwrap_err()
1224 .to_string()
1225 .contains("single path segment")
1226 );
1227 }
1228
1229 #[test]
1230 fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1231 let temp_dir = tempfile::TempDir::new().unwrap();
1232 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1233 let cfg = RepoConfigV2 {
1234 version: "2.0".to_string(),
1235 mount_dirs: MountDirsV2::default(),
1236 thoughts_mount: None,
1237 context_mounts: vec![ContextMount {
1238 remote: "git@github.com:org/repo.git".to_string(),
1239 subpath: None,
1240 mount_path: "docs".to_string(),
1241 sync: SyncStrategy::Auto,
1242 }],
1243 references: vec![],
1244 };
1245 let result = mgr.validate_v2_hard(&cfg);
1246 assert!(result.is_ok());
1247 let warnings = result.unwrap();
1248 assert_eq!(warnings.len(), 0);
1249 }
1250
1251 #[test]
1252 fn test_new_makes_absolute_when_given_relative_repo_root() {
1253 let temp_dir = TempDir::new().unwrap();
1254 let cwd_before = std::env::current_dir().unwrap();
1255
1256 std::env::set_current_dir(temp_dir.path()).unwrap();
1258
1259 std::fs::create_dir_all("repo").unwrap();
1261
1262 let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1263
1264 assert!(mgr.peek_config_version().is_ok());
1268
1269 std::env::set_current_dir(cwd_before).unwrap();
1271 }
1272
1273 #[test]
1280 fn test_validate_v2_hard_rejects_trailing_slash_in_mount_dirs() {
1281 let temp_dir = tempfile::TempDir::new().unwrap();
1282 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1283
1284 let cfg = RepoConfigV2 {
1286 version: "2.0".to_string(),
1287 mount_dirs: MountDirsV2 {
1288 thoughts: "thoughts/".to_string(),
1289 context: "context".to_string(),
1290 references: "references".to_string(),
1291 },
1292 thoughts_mount: None,
1293 context_mounts: vec![],
1294 references: vec![],
1295 };
1296 let result = mgr.validate_v2_hard(&cfg);
1297 assert!(
1298 result.is_err(),
1299 "trailing slash on thoughts should be rejected"
1300 );
1301 assert!(
1302 result
1303 .unwrap_err()
1304 .to_string()
1305 .contains("single path segment"),
1306 "error should mention single path segment requirement"
1307 );
1308
1309 let cfg = RepoConfigV2 {
1311 version: "2.0".to_string(),
1312 mount_dirs: MountDirsV2 {
1313 thoughts: "thoughts".to_string(),
1314 context: "context/".to_string(),
1315 references: "references".to_string(),
1316 },
1317 thoughts_mount: None,
1318 context_mounts: vec![],
1319 references: vec![],
1320 };
1321 let result = mgr.validate_v2_hard(&cfg);
1322 assert!(
1323 result.is_err(),
1324 "trailing slash on context should be rejected"
1325 );
1326
1327 let cfg = RepoConfigV2 {
1329 version: "2.0".to_string(),
1330 mount_dirs: MountDirsV2 {
1331 thoughts: "thoughts".to_string(),
1332 context: "context".to_string(),
1333 references: "references/".to_string(),
1334 },
1335 thoughts_mount: None,
1336 context_mounts: vec![],
1337 references: vec![],
1338 };
1339 let result = mgr.validate_v2_hard(&cfg);
1340 assert!(
1341 result.is_err(),
1342 "trailing slash on references should be rejected"
1343 );
1344 }
1345}