1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::agents::AgentId;
9use crate::error::RepographError;
10
11pub const CONFIG_FILE_NAME: &str = "config.toml";
13
14pub const MAX_WORKSPACE_NAME_LEN: usize = 63;
16
17pub const RESERVED_WORKSPACE_NAMES: &[&str] = &["default", "all", "none"];
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Repo {
25 pub path: PathBuf,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub description: Option<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub stack: Vec<String>,
30}
31
32#[derive(Debug, Default, Clone)]
43pub struct RepoEdit {
44 pub new_name: Option<String>,
45 pub description: Option<Option<String>>,
46 pub stack: Option<Vec<String>>,
47 pub path: Option<PathBuf>,
48}
49
50#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Workspace {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub description: Option<String>,
59 #[serde(default)]
60 pub members: Vec<String>,
61}
62
63pub type WorkspaceResolution<'a> = (Vec<(&'a String, &'a Repo)>, Vec<&'a String>);
67
68#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Agents {
76 #[serde(default)]
77 pub selected: Vec<AgentId>,
78}
79
80#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Settings {
88 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub projects_root: Option<PathBuf>,
95}
96
97#[derive(Debug, Default, Clone, Serialize, Deserialize)]
100pub struct Config {
101 #[serde(default, rename = "repo", skip_serializing_if = "BTreeMap::is_empty")]
102 repos: BTreeMap<String, Repo>,
103 #[serde(
104 default,
105 rename = "workspace",
106 skip_serializing_if = "BTreeMap::is_empty"
107 )]
108 workspaces: BTreeMap<String, Workspace>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
113 agents: Option<Agents>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 settings: Option<Settings>,
118}
119
120impl Config {
121 #[must_use]
123 pub const fn repos(&self) -> &BTreeMap<String, Repo> {
124 &self.repos
125 }
126
127 #[must_use]
129 pub const fn workspaces(&self) -> &BTreeMap<String, Workspace> {
130 &self.workspaces
131 }
132
133 #[must_use]
137 pub const fn agents(&self) -> Option<&Agents> {
138 self.agents.as_ref()
139 }
140
141 pub fn set_agents(&mut self, agents: Option<Agents>) {
146 self.agents = agents;
147 }
148
149 #[must_use]
153 pub const fn settings(&self) -> Option<&Settings> {
154 self.settings.as_ref()
155 }
156
157 pub fn set_settings(&mut self, settings: Option<Settings>) {
159 self.settings = settings;
160 }
161
162 #[must_use]
166 pub fn default_dir() -> Option<PathBuf> {
167 dirs::config_dir().map(|d| d.join("repograph"))
168 }
169
170 pub fn load(dir: &Path) -> Result<Self, RepographError> {
178 let path = dir.join(CONFIG_FILE_NAME);
179 match fs_err::read_to_string(&path) {
180 Ok(body) => Ok(toml::from_str(&body)?),
181 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
182 Err(e) => Err(e.into()),
183 }
184 }
185
186 pub fn save(&self, dir: &Path) -> Result<(), RepographError> {
197 let body = toml::to_string_pretty(self)?;
198 let target = dir.join(CONFIG_FILE_NAME);
199
200 if let Err(e) = fs_err::create_dir_all(dir) {
201 return Err(map_io_to_perm(e, dir));
202 }
203
204 let tmp = dir.join(format!(".{CONFIG_FILE_NAME}.tmp"));
205 if let Err(e) = fs_err::write(&tmp, body.as_bytes()) {
206 return Err(map_io_to_perm(e, &tmp));
207 }
208 if let Err(e) = fs_err::rename(&tmp, &target) {
209 return Err(map_io_to_perm(e, &target));
210 }
211 Ok(())
212 }
213
214 pub fn add_repo(&mut self, name: String, repo: Repo) -> Result<(), RepographError> {
222 if self.repos.contains_key(&name) {
223 return Err(RepographError::Conflict { kind: "name", name });
224 }
225 if let Some((existing_name, _)) = self.repos.iter().find(|(_, r)| r.path == repo.path) {
226 return Err(RepographError::Conflict {
227 kind: "path",
228 name: existing_name.clone(),
229 });
230 }
231 self.repos.insert(name, repo);
232 Ok(())
233 }
234
235 pub fn remove_repo(&mut self, name: &str) -> Result<Repo, RepographError> {
241 self.repos
242 .remove(name)
243 .ok_or_else(|| RepographError::NotFound {
244 kind: "repo",
245 name: name.to_string(),
246 })
247 }
248
249 pub fn edit_repo(
264 &mut self,
265 name: &str,
266 edit: RepoEdit,
267 ) -> Result<(String, Repo), RepographError> {
268 if !self.repos.contains_key(name) {
269 return Err(RepographError::NotFound {
270 kind: "repo",
271 name: name.to_string(),
272 });
273 }
274
275 let rename_to = edit
277 .new_name
278 .as_deref()
279 .filter(|n| *n != name)
280 .map(ToString::to_string);
281
282 if let Some(new_name) = &rename_to {
284 if self.repos.contains_key(new_name) {
285 return Err(RepographError::Conflict {
286 kind: "name",
287 name: new_name.clone(),
288 });
289 }
290 }
291 if let Some(new_path) = &edit.path {
293 if let Some((existing, _)) = self
294 .repos
295 .iter()
296 .find(|(k, r)| k.as_str() != name && &r.path == new_path)
297 {
298 return Err(RepographError::Conflict {
299 kind: "path",
300 name: existing.clone(),
301 });
302 }
303 }
304
305 let mut repo = self
308 .repos
309 .remove(name)
310 .ok_or_else(|| RepographError::NotFound {
311 kind: "repo",
312 name: name.to_string(),
313 })?;
314 if let Some(description) = edit.description {
315 repo.description = description.filter(|s| !s.is_empty());
316 }
317 if let Some(stack) = edit.stack {
318 repo.stack = stack;
319 }
320 if let Some(path) = edit.path {
321 repo.path = path;
322 }
323
324 let final_name = rename_to.clone().unwrap_or_else(|| name.to_string());
325 self.repos.insert(final_name.clone(), repo.clone());
326
327 if let Some(new_name) = &rename_to {
329 for ws in self.workspaces.values_mut() {
330 let mut touched = false;
331 for member in &mut ws.members {
332 if member == name {
333 member.clone_from(new_name);
334 touched = true;
335 }
336 }
337 if touched {
338 ws.members.sort();
339 ws.members.dedup();
340 }
341 }
342 }
343
344 Ok((final_name, repo))
345 }
346
347 pub fn create_workspace(
358 &mut self,
359 name: String,
360 description: Option<String>,
361 ) -> Result<(), RepographError> {
362 validate_workspace_name(&name)?;
363 if self.workspaces.contains_key(&name) {
364 return Err(RepographError::Conflict {
365 kind: "workspace",
366 name,
367 });
368 }
369 self.workspaces.insert(
370 name,
371 Workspace {
372 description: description.filter(|s| !s.is_empty()),
373 members: Vec::new(),
374 },
375 );
376 Ok(())
377 }
378
379 pub fn remove_workspace(&mut self, name: &str) -> Result<Workspace, RepographError> {
386 self.workspaces
387 .remove(name)
388 .ok_or_else(|| RepographError::NotFound {
389 kind: "workspace",
390 name: name.to_string(),
391 })
392 }
393
394 pub fn add_members(&mut self, workspace: &str, repos: &[String]) -> Result<(), RepographError> {
407 if !self.workspaces.contains_key(workspace) {
410 return Err(RepographError::NotFound {
411 kind: "workspace",
412 name: workspace.to_string(),
413 });
414 }
415 for name in repos {
416 if !self.repos.contains_key(name) {
417 return Err(RepographError::NotFound {
418 kind: "repo",
419 name: name.clone(),
420 });
421 }
422 }
423 let ws = self
426 .workspaces
427 .get_mut(workspace)
428 .ok_or_else(|| RepographError::NotFound {
429 kind: "workspace",
430 name: workspace.to_string(),
431 })?;
432 for name in repos {
433 ws.members.push(name.clone());
434 }
435 ws.members.sort();
436 ws.members.dedup();
437 Ok(())
438 }
439
440 pub fn remove_members(
448 &mut self,
449 workspace: &str,
450 repos: &[String],
451 ) -> Result<(), RepographError> {
452 let ws = self
453 .workspaces
454 .get_mut(workspace)
455 .ok_or_else(|| RepographError::NotFound {
456 kind: "workspace",
457 name: workspace.to_string(),
458 })?;
459 ws.members.retain(|m| !repos.iter().any(|r| r == m));
460 Ok(())
461 }
462
463 pub fn resolve_workspace<'a>(
474 &'a self,
475 workspace: &str,
476 ) -> Result<WorkspaceResolution<'a>, RepographError> {
477 let ws = self
478 .workspaces
479 .get(workspace)
480 .ok_or_else(|| RepographError::NotFound {
481 kind: "workspace",
482 name: workspace.to_string(),
483 })?;
484 let mut live = Vec::with_capacity(ws.members.len());
485 let mut dangling = Vec::new();
486 for name in &ws.members {
487 if let Some((key, repo)) = self.repos.get_key_value(name) {
488 live.push((key, repo));
489 } else {
490 dangling.push(name);
491 }
492 }
493 Ok((live, dangling))
494 }
495}
496
497pub fn validate_workspace_name(name: &str) -> Result<(), RepographError> {
506 if name.is_empty() {
507 return Err(invalid_workspace_name(name, "must not be empty"));
508 }
509 if name.len() > MAX_WORKSPACE_NAME_LEN {
510 return Err(invalid_workspace_name(
511 name,
512 "must be at most 63 characters",
513 ));
514 }
515 if RESERVED_WORKSPACE_NAMES.contains(&name) {
516 return Err(invalid_workspace_name(name, "is a reserved name"));
517 }
518 for (i, c) in name.chars().enumerate() {
519 let alnum_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
520 if i == 0 {
521 if !alnum_lower {
522 return Err(invalid_workspace_name(
523 name,
524 "must start with a lowercase letter or digit",
525 ));
526 }
527 } else if !alnum_lower && c != '-' {
528 return Err(invalid_workspace_name(
529 name,
530 "must contain only lowercase letters, digits, and hyphens",
531 ));
532 }
533 }
534 Ok(())
535}
536
537fn invalid_workspace_name(name: &str, reason: &'static str) -> RepographError {
538 RepographError::InvalidName {
539 kind: "workspace",
540 name: name.to_string(),
541 reason,
542 }
543}
544
545fn map_io_to_perm(e: std::io::Error, path: &Path) -> RepographError {
548 if e.kind() == std::io::ErrorKind::PermissionDenied {
549 RepographError::PermissionDenied {
550 path: path.to_path_buf(),
551 }
552 } else {
553 RepographError::Io(e)
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 #![allow(clippy::unwrap_used, clippy::expect_used)]
560 use super::*;
561 use tempfile::TempDir;
562
563 fn make(path: &str) -> Repo {
564 Repo {
565 path: PathBuf::from(path),
566 description: None,
567 stack: vec![],
568 }
569 }
570
571 #[test]
572 fn load_missing_returns_empty() {
573 let tmp = TempDir::new().unwrap();
574 let cfg = Config::load(tmp.path()).unwrap();
575 assert!(cfg.repos.is_empty());
576 }
577
578 #[test]
579 fn save_then_load_round_trip() {
580 let tmp = TempDir::new().unwrap();
581 let mut cfg = Config::default();
582 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
583 cfg.add_repo(
584 "bar".into(),
585 Repo {
586 path: PathBuf::from("/tmp/bar"),
587 description: Some("hi".into()),
588 stack: vec!["rust".into()],
589 },
590 )
591 .unwrap();
592
593 cfg.save(tmp.path()).unwrap();
594 let loaded = Config::load(tmp.path()).unwrap();
595 assert_eq!(loaded.repos.len(), 2);
596 assert_eq!(
597 loaded.repos.get("bar").unwrap().description.as_deref(),
598 Some("hi")
599 );
600 }
601
602 #[test]
603 fn name_conflict_blocks_insert() {
604 let mut cfg = Config::default();
605 cfg.add_repo("foo".into(), make("/a")).unwrap();
606 let err = cfg.add_repo("foo".into(), make("/b")).unwrap_err();
607 assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
608 assert_eq!(cfg.repos.get("foo").unwrap().path, PathBuf::from("/a"));
609 }
610
611 #[test]
612 fn path_conflict_blocks_insert() {
613 let mut cfg = Config::default();
614 cfg.add_repo("foo".into(), make("/shared")).unwrap();
615 let err = cfg.add_repo("bar".into(), make("/shared")).unwrap_err();
616 assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
617 assert!(!cfg.repos.contains_key("bar"));
618 }
619
620 #[test]
621 fn remove_missing_returns_not_found() {
622 let mut cfg = Config::default();
623 let err = cfg.remove_repo("ghost").unwrap_err();
624 assert!(matches!(err, RepographError::NotFound { .. }));
625 }
626
627 #[test]
628 fn unknown_field_in_toml_is_tolerated() {
629 let tmp = TempDir::new().unwrap();
630 std::fs::create_dir_all(tmp.path()).unwrap();
631 std::fs::write(
632 tmp.path().join(CONFIG_FILE_NAME),
633 "[repo.foo]\npath = \"/tmp/foo\"\nfuture = \"yes\"\n",
634 )
635 .unwrap();
636 let cfg = Config::load(tmp.path()).unwrap();
637 assert!(cfg.repos.contains_key("foo"));
638 }
639
640 #[test]
643 fn validate_workspace_name_accepts_simple_lowercase() {
644 assert!(validate_workspace_name("acme").is_ok());
645 assert!(validate_workspace_name("acme-rebuild-2026").is_ok());
646 assert!(validate_workspace_name("a").is_ok());
647 assert!(validate_workspace_name("0").is_ok());
648 assert!(validate_workspace_name("0acme").is_ok());
649 }
650
651 #[test]
652 fn validate_workspace_name_rejects_empty() {
653 let err = validate_workspace_name("").unwrap_err();
654 assert!(matches!(err, RepographError::InvalidName { .. }));
655 assert_eq!(err.exit_code(), 2);
656 }
657
658 #[test]
659 fn validate_workspace_name_rejects_uppercase() {
660 let err = validate_workspace_name("AcmeRebuild").unwrap_err();
661 assert!(matches!(err, RepographError::InvalidName { .. }));
662 }
663
664 #[test]
665 fn validate_workspace_name_rejects_leading_hyphen() {
666 let err = validate_workspace_name("-acme").unwrap_err();
667 assert!(matches!(err, RepographError::InvalidName { .. }));
668 }
669
670 #[test]
671 fn validate_workspace_name_rejects_underscore() {
672 let err = validate_workspace_name("ac_me").unwrap_err();
673 assert!(matches!(err, RepographError::InvalidName { .. }));
674 }
675
676 #[test]
677 fn validate_workspace_name_rejects_spaces() {
678 let err = validate_workspace_name("ac me").unwrap_err();
679 assert!(matches!(err, RepographError::InvalidName { .. }));
680 }
681
682 #[test]
683 fn validate_workspace_name_rejects_overlength() {
684 let name = "a".repeat(MAX_WORKSPACE_NAME_LEN + 1);
685 let err = validate_workspace_name(&name).unwrap_err();
686 assert!(matches!(err, RepographError::InvalidName { .. }));
687 }
688
689 #[test]
690 fn validate_workspace_name_accepts_exact_max_length() {
691 let name = "a".repeat(MAX_WORKSPACE_NAME_LEN);
692 assert!(validate_workspace_name(&name).is_ok());
693 }
694
695 #[test]
696 fn validate_workspace_name_rejects_reserved_words() {
697 for reserved in RESERVED_WORKSPACE_NAMES {
698 let err = validate_workspace_name(reserved).unwrap_err();
699 assert!(
700 matches!(err, RepographError::InvalidName { .. }),
701 "reserved `{reserved}` must be rejected"
702 );
703 }
704 }
705
706 #[test]
707 fn create_workspace_inserts_empty_entry() {
708 let mut cfg = Config::default();
709 cfg.create_workspace("acme".into(), None).unwrap();
710 let ws = cfg.workspaces.get("acme").unwrap();
711 assert!(ws.description.is_none());
712 assert!(ws.members.is_empty());
713 }
714
715 #[test]
716 fn create_workspace_persists_description() {
717 let mut cfg = Config::default();
718 cfg.create_workspace("acme".into(), Some("rebuild".into()))
719 .unwrap();
720 assert_eq!(
721 cfg.workspaces.get("acme").unwrap().description.as_deref(),
722 Some("rebuild")
723 );
724 }
725
726 #[test]
727 fn create_workspace_conflict_returns_conflict() {
728 let mut cfg = Config::default();
729 cfg.create_workspace("acme".into(), None).unwrap();
730 let err = cfg.create_workspace("acme".into(), None).unwrap_err();
731 assert!(matches!(
732 err,
733 RepographError::Conflict {
734 kind: "workspace",
735 ..
736 }
737 ));
738 assert_eq!(err.exit_code(), 5);
739 }
740
741 #[test]
742 fn create_workspace_invalid_name_returns_invalid_name() {
743 let mut cfg = Config::default();
744 let err = cfg.create_workspace("Bad Name".into(), None).unwrap_err();
745 assert!(matches!(err, RepographError::InvalidName { .. }));
746 assert_eq!(err.exit_code(), 2);
747 assert!(cfg.workspaces.is_empty());
748 }
749
750 #[test]
751 fn remove_workspace_missing_returns_not_found() {
752 let mut cfg = Config::default();
753 let err = cfg.remove_workspace("ghost").unwrap_err();
754 assert!(matches!(
755 err,
756 RepographError::NotFound {
757 kind: "workspace",
758 ..
759 }
760 ));
761 assert_eq!(err.exit_code(), 3);
762 }
763
764 #[test]
765 fn remove_workspace_does_not_touch_repos() {
766 let mut cfg = Config::default();
767 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
768 cfg.create_workspace("acme".into(), None).unwrap();
769 cfg.add_members("acme", &["api".into()]).unwrap();
770 cfg.remove_workspace("acme").unwrap();
771 assert!(cfg.repos.contains_key("api"));
772 assert!(!cfg.workspaces.contains_key("acme"));
773 }
774
775 #[test]
776 fn add_members_atomic_when_one_repo_missing() {
777 let mut cfg = Config::default();
778 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
779 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
780 cfg.create_workspace("acme".into(), None).unwrap();
781 let err = cfg
782 .add_members("acme", &["api".into(), "ghost".into(), "ui".into()])
783 .unwrap_err();
784 assert!(matches!(
785 err,
786 RepographError::NotFound { kind: "repo", ref name } if name == "ghost"
787 ));
788 assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
790 }
791
792 #[test]
793 fn add_members_sorts_and_deduplicates() {
794 let mut cfg = Config::default();
795 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
796 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
797 cfg.add_repo("libs".into(), make("/tmp/libs")).unwrap();
798 cfg.create_workspace("acme".into(), None).unwrap();
799 cfg.add_members("acme", &["ui".into(), "api".into(), "libs".into()])
800 .unwrap();
801 assert_eq!(
802 cfg.workspaces.get("acme").unwrap().members,
803 vec!["api", "libs", "ui"]
804 );
805 cfg.add_members("acme", &["api".into()]).unwrap();
807 assert_eq!(
808 cfg.workspaces.get("acme").unwrap().members,
809 vec!["api", "libs", "ui"]
810 );
811 }
812
813 #[test]
814 fn add_members_missing_workspace_returns_not_found() {
815 let mut cfg = Config::default();
816 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
817 let err = cfg.add_members("ghost", &["api".into()]).unwrap_err();
818 assert!(matches!(
819 err,
820 RepographError::NotFound {
821 kind: "workspace",
822 ..
823 }
824 ));
825 }
826
827 #[test]
828 fn remove_members_is_idempotent_for_non_members() {
829 let mut cfg = Config::default();
830 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
831 cfg.create_workspace("acme".into(), None).unwrap();
832 cfg.add_members("acme", &["api".into()]).unwrap();
833 cfg.remove_members("acme", &["ghost".into()]).unwrap();
834 assert_eq!(cfg.workspaces.get("acme").unwrap().members, vec!["api"]);
835 }
836
837 #[test]
838 fn remove_members_does_not_deregister_repo() {
839 let mut cfg = Config::default();
840 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
841 cfg.create_workspace("acme".into(), None).unwrap();
842 cfg.add_members("acme", &["api".into()]).unwrap();
843 cfg.remove_members("acme", &["api".into()]).unwrap();
844 assert!(cfg.repos.contains_key("api"));
845 assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
846 }
847
848 #[test]
849 fn resolve_workspace_partitions_live_and_dangling() {
850 let mut cfg = Config::default();
851 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
852 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
853 cfg.create_workspace("acme".into(), None).unwrap();
854 cfg.add_members("acme", &["api".into(), "ui".into()])
855 .unwrap();
856 cfg.remove_repo("ui").unwrap();
858 let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
859 assert_eq!(live.len(), 1);
860 assert_eq!(live[0].0, "api");
861 assert_eq!(dangling.len(), 1);
862 assert_eq!(dangling[0], "ui");
863 }
864
865 #[test]
866 fn resolve_workspace_recovers_after_reregistration() {
867 let mut cfg = Config::default();
868 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
869 cfg.create_workspace("acme".into(), None).unwrap();
870 cfg.add_members("acme", &["api".into()]).unwrap();
871 cfg.remove_repo("api").unwrap();
872 let (_, dangling) = cfg.resolve_workspace("acme").unwrap();
873 assert_eq!(dangling, vec!["api"]);
874 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
875 let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
876 assert_eq!(live.len(), 1);
877 assert!(dangling.is_empty());
878 }
879
880 #[test]
881 fn round_trip_with_mixed_repos_and_workspaces() {
882 let tmp = TempDir::new().unwrap();
883 let mut cfg = Config::default();
884 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
885 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
886 cfg.create_workspace("acme".into(), Some("Rebuild".into()))
887 .unwrap();
888 cfg.add_members("acme", &["ui".into(), "api".into()])
889 .unwrap();
890 cfg.create_workspace("billing".into(), None).unwrap();
891 cfg.save(tmp.path()).unwrap();
892
893 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
894 let loaded = Config::load(tmp.path()).unwrap();
895 loaded.save(tmp.path()).unwrap();
896 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
897 assert_eq!(body_first, body_second, "byte-identical round trip");
898
899 assert_eq!(loaded.workspaces.len(), 2);
900 let acme = loaded.workspaces.get("acme").unwrap();
901 assert_eq!(acme.description.as_deref(), Some("Rebuild"));
902 assert_eq!(acme.members, vec!["api", "ui"]);
903 let billing = loaded.workspaces.get("billing").unwrap();
904 assert!(billing.description.is_none());
905 assert!(billing.members.is_empty());
906 }
907
908 #[test]
911 fn config_without_agents_section_loads_as_none() {
912 let tmp = TempDir::new().unwrap();
913 std::fs::create_dir_all(tmp.path()).unwrap();
914 std::fs::write(
915 tmp.path().join(CONFIG_FILE_NAME),
916 "[repo.foo]\npath = \"/tmp/foo\"\n",
917 )
918 .unwrap();
919 let cfg = Config::load(tmp.path()).unwrap();
920 assert!(cfg.agents().is_none());
921 assert!(cfg.repos.contains_key("foo"));
922 }
923
924 #[test]
925 fn config_with_empty_agents_is_some_with_empty_selection() {
926 let tmp = TempDir::new().unwrap();
927 std::fs::create_dir_all(tmp.path()).unwrap();
928 std::fs::write(
929 tmp.path().join(CONFIG_FILE_NAME),
930 "[agents]\nselected = []\n",
931 )
932 .unwrap();
933 let cfg = Config::load(tmp.path()).unwrap();
934 let agents = cfg.agents().expect("agents present");
935 assert!(agents.selected.is_empty());
936 }
937
938 #[test]
939 fn save_with_agents_none_omits_section() {
940 let tmp = TempDir::new().unwrap();
941 let mut cfg = Config::default();
942 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
943 cfg.save(tmp.path()).unwrap();
945 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
946 assert!(
947 !body.contains("[agents]"),
948 "no [agents] section when agents is None, got:\n{body}"
949 );
950 }
951
952 #[test]
953 fn save_with_empty_agents_writes_section_header() {
954 let tmp = TempDir::new().unwrap();
955 let mut cfg = Config::default();
956 cfg.set_agents(Some(Agents { selected: vec![] }));
957 cfg.save(tmp.path()).unwrap();
958 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
959 assert!(
960 body.contains("[agents]"),
961 "configured-but-empty still writes section header, got:\n{body}"
962 );
963 }
964
965 #[test]
966 fn agents_selection_order_round_trips() {
967 let tmp = TempDir::new().unwrap();
968 let mut cfg = Config::default();
969 cfg.set_agents(Some(Agents {
970 selected: vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd],
971 }));
972 cfg.save(tmp.path()).unwrap();
973 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
974 let cursor = body.find("\"cursor\"").expect("cursor present");
975 let claude = body.find("\"claude-code\"").expect("claude-code present");
976 let agents_md = body.find("\"agents-md\"").expect("agents-md present");
977 assert!(cursor < claude && claude < agents_md, "order preserved");
978
979 let reloaded = Config::load(tmp.path()).unwrap();
980 assert_eq!(
981 reloaded.agents().unwrap().selected,
982 vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd]
983 );
984 }
985
986 #[test]
987 fn agents_round_trip_with_repos_and_workspaces_is_byte_stable() {
988 let tmp = TempDir::new().unwrap();
989 let mut cfg = Config::default();
990 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
991 cfg.create_workspace("acme".into(), None).unwrap();
992 cfg.add_members("acme", &["api".into()]).unwrap();
993 cfg.set_agents(Some(Agents {
994 selected: vec![AgentId::ClaudeCode],
995 }));
996 cfg.save(tmp.path()).unwrap();
997 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
998
999 let loaded = Config::load(tmp.path()).unwrap();
1000 loaded.save(tmp.path()).unwrap();
1001 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1002 assert_eq!(
1003 body_first, body_second,
1004 "round-trip byte-identical with [agents]"
1005 );
1006 }
1007
1008 #[test]
1009 fn unknown_agent_id_in_config_produces_parse_error() {
1010 let tmp = TempDir::new().unwrap();
1011 std::fs::create_dir_all(tmp.path()).unwrap();
1012 std::fs::write(
1013 tmp.path().join(CONFIG_FILE_NAME),
1014 "[agents]\nselected = [\"claude-code\", \"bogus\"]\n",
1015 )
1016 .unwrap();
1017 let err = Config::load(tmp.path()).unwrap_err();
1018 assert!(
1019 matches!(err, RepographError::ConfigParse(_)),
1020 "expected ConfigParse, got {err:?}"
1021 );
1022 assert_eq!(err.exit_code(), 1);
1023 }
1024
1025 #[test]
1028 fn config_without_settings_section_loads_as_none() {
1029 let tmp = TempDir::new().unwrap();
1030 std::fs::create_dir_all(tmp.path()).unwrap();
1031 std::fs::write(
1032 tmp.path().join(CONFIG_FILE_NAME),
1033 "[agents]\nselected = []\n",
1034 )
1035 .unwrap();
1036 let cfg = Config::load(tmp.path()).unwrap();
1037 assert!(cfg.settings().is_none());
1038 }
1039
1040 #[test]
1041 fn save_with_settings_none_omits_section() {
1042 let tmp = TempDir::new().unwrap();
1043 let mut cfg = Config::default();
1044 cfg.set_agents(Some(Agents {
1045 selected: vec![AgentId::ClaudeCode],
1046 }));
1047 cfg.save(tmp.path()).unwrap();
1049 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1050 assert!(
1051 !body.contains("[settings]"),
1052 "no [settings] section when settings is None, got:\n{body}"
1053 );
1054 }
1055
1056 #[test]
1057 fn settings_projects_root_round_trip() {
1058 let tmp = TempDir::new().unwrap();
1059 let mut cfg = Config::default();
1060 cfg.set_settings(Some(Settings {
1061 projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
1062 }));
1063 cfg.save(tmp.path()).unwrap();
1064 let reloaded = Config::load(tmp.path()).unwrap();
1065 assert_eq!(
1066 reloaded.settings().unwrap().projects_root.as_deref(),
1067 Some(Path::new("/home/dev/IdeaProjects"))
1068 );
1069 }
1070
1071 #[test]
1072 fn settings_with_none_projects_root_still_writes_section_header() {
1073 let tmp = TempDir::new().unwrap();
1074 let mut cfg = Config::default();
1075 cfg.set_settings(Some(Settings::default()));
1076 cfg.save(tmp.path()).unwrap();
1077 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1078 assert!(
1079 body.contains("[settings]"),
1080 "configured-but-empty settings still writes header, got:\n{body}"
1081 );
1082 assert!(
1083 !body.contains("projects_root"),
1084 "absent field is omitted, got:\n{body}"
1085 );
1086 }
1087
1088 #[test]
1089 fn settings_round_trip_with_agents_and_repos_is_byte_stable() {
1090 let tmp = TempDir::new().unwrap();
1091 let mut cfg = Config::default();
1092 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
1093 cfg.set_agents(Some(Agents {
1094 selected: vec![AgentId::ClaudeCode],
1095 }));
1096 cfg.set_settings(Some(Settings {
1097 projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
1098 }));
1099 cfg.save(tmp.path()).unwrap();
1100 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1101
1102 let loaded = Config::load(tmp.path()).unwrap();
1103 loaded.save(tmp.path()).unwrap();
1104 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1105 assert_eq!(
1106 body_first, body_second,
1107 "round-trip byte-identical with [settings]"
1108 );
1109 }
1110
1111 #[test]
1112 fn unknown_field_on_workspace_is_tolerated() {
1113 let tmp = TempDir::new().unwrap();
1114 std::fs::create_dir_all(tmp.path()).unwrap();
1115 std::fs::write(
1116 tmp.path().join(CONFIG_FILE_NAME),
1117 "[workspace.acme]\nmembers = []\nfuture = \"yes\"\n",
1118 )
1119 .unwrap();
1120 let cfg = Config::load(tmp.path()).unwrap();
1121 assert!(cfg.workspaces.contains_key("acme"));
1122 }
1123
1124 #[test]
1125 fn edit_repo_updates_description_and_stack_in_place() {
1126 let mut cfg = Config::default();
1127 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1128 let (name, repo) = cfg
1129 .edit_repo(
1130 "foo",
1131 RepoEdit {
1132 description: Some(Some("new".into())),
1133 stack: Some(vec!["rust".into(), "cli".into()]),
1134 ..RepoEdit::default()
1135 },
1136 )
1137 .unwrap();
1138 assert_eq!(name, "foo");
1139 assert_eq!(repo.description.as_deref(), Some("new"));
1140 assert_eq!(repo.stack, vec!["rust", "cli"]);
1141 assert_eq!(
1142 cfg.repos.get("foo").unwrap().path,
1143 PathBuf::from("/tmp/foo")
1144 );
1145 }
1146
1147 #[test]
1148 fn edit_repo_empty_description_clears_it() {
1149 let mut cfg = Config::default();
1150 cfg.add_repo(
1151 "foo".into(),
1152 Repo {
1153 path: PathBuf::from("/tmp/foo"),
1154 description: Some("old".into()),
1155 stack: vec![],
1156 },
1157 )
1158 .unwrap();
1159 cfg.edit_repo(
1160 "foo",
1161 RepoEdit {
1162 description: Some(None),
1163 ..RepoEdit::default()
1164 },
1165 )
1166 .unwrap();
1167 assert!(cfg.repos.get("foo").unwrap().description.is_none());
1168 }
1169
1170 #[test]
1171 fn edit_repo_rename_preserves_workspace_membership() {
1172 let mut cfg = Config::default();
1173 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1174 cfg.create_workspace("acme".into(), None).unwrap();
1175 cfg.add_members("acme", &["foo".into()]).unwrap();
1176
1177 let (name, _) = cfg
1178 .edit_repo(
1179 "foo",
1180 RepoEdit {
1181 new_name: Some("bar".into()),
1182 ..RepoEdit::default()
1183 },
1184 )
1185 .unwrap();
1186 assert_eq!(name, "bar");
1187 assert!(cfg.repos.contains_key("bar"));
1188 assert!(!cfg.repos.contains_key("foo"));
1189 let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
1191 assert!(dangling.is_empty(), "rename left a dangling member");
1192 assert_eq!(live.len(), 1);
1193 assert_eq!(live[0].0, "bar");
1194 }
1195
1196 #[test]
1197 fn edit_repo_rename_to_existing_name_conflicts() {
1198 let mut cfg = Config::default();
1199 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1200 cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
1201 let err = cfg
1202 .edit_repo(
1203 "foo",
1204 RepoEdit {
1205 new_name: Some("bar".into()),
1206 ..RepoEdit::default()
1207 },
1208 )
1209 .unwrap_err();
1210 assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
1211 assert!(cfg.repos.contains_key("foo"));
1213 assert_eq!(
1214 cfg.repos.get("bar").unwrap().path,
1215 PathBuf::from("/tmp/bar")
1216 );
1217 }
1218
1219 #[test]
1220 fn edit_repo_nonexistent_returns_not_found() {
1221 let mut cfg = Config::default();
1222 let err = cfg
1223 .edit_repo(
1224 "ghost",
1225 RepoEdit {
1226 description: Some(Some("x".into())),
1227 ..RepoEdit::default()
1228 },
1229 )
1230 .unwrap_err();
1231 assert!(matches!(err, RepographError::NotFound { kind: "repo", .. }));
1232 }
1233
1234 #[test]
1235 fn edit_repo_path_conflict_returns_conflict() {
1236 let mut cfg = Config::default();
1237 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1238 cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
1239 let err = cfg
1240 .edit_repo(
1241 "foo",
1242 RepoEdit {
1243 path: Some(PathBuf::from("/tmp/bar")),
1244 ..RepoEdit::default()
1245 },
1246 )
1247 .unwrap_err();
1248 assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
1249 assert_eq!(
1250 cfg.repos.get("foo").unwrap().path,
1251 PathBuf::from("/tmp/foo")
1252 );
1253 }
1254
1255 #[test]
1256 fn edit_repo_rename_to_same_name_is_noop_not_conflict() {
1257 let mut cfg = Config::default();
1258 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1259 let (name, _) = cfg
1260 .edit_repo(
1261 "foo",
1262 RepoEdit {
1263 new_name: Some("foo".into()),
1264 description: Some(Some("d".into())),
1265 ..RepoEdit::default()
1266 },
1267 )
1268 .unwrap();
1269 assert_eq!(name, "foo");
1270 assert_eq!(
1271 cfg.repos.get("foo").unwrap().description.as_deref(),
1272 Some("d")
1273 );
1274 }
1275}