Skip to main content

repograph_core/
config.rs

1//! Config model and TOML persistence.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::agents::AgentId;
9use crate::error::RepographError;
10
11/// On-disk file name within the config directory.
12pub const CONFIG_FILE_NAME: &str = "config.toml";
13
14/// Maximum length of a workspace name (RFC 1123 label rule).
15pub const MAX_WORKSPACE_NAME_LEN: usize = 63;
16
17/// Reserved workspace names. These collide with future filter ergonomics
18/// (e.g. `--workspace all`) and are rejected at write time.
19pub const RESERVED_WORKSPACE_NAMES: &[&str] = &["default", "all", "none"];
20
21/// A registered local git repository. The `name` is the map key in
22/// [`Config::repos`] — it does not appear as a field on this struct.
23#[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/// A named grouping of registered repositories.
33///
34/// The `name` is the map key in [`Config::workspaces`] — it does not appear
35/// as a field on this struct. `members` holds bare repo names (keys into
36/// [`Config::repos`]) and is kept sorted on write for round-trip stability.
37#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Workspace {
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    #[serde(default)]
42    pub members: Vec<String>,
43}
44
45/// Result of resolving a workspace's `members` against the repo registry:
46/// `(live, dangling)`. Live entries borrow the repo's name and its
47/// [`Repo`] entry; dangling entries borrow only the orphaned name.
48pub type WorkspaceResolution<'a> = (Vec<(&'a String, &'a Repo)>, Vec<&'a String>);
49
50/// The `[agents]` section of the on-disk config. Presence of this section
51/// signals that `repograph init` has been run; absence triggers the first-run
52/// prompt the next time an agent-consuming command runs.
53///
54/// `selected` preserves the order the user chose at init time so the rendered
55/// config and any downstream agent prompt have stable, predictable output.
56#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Agents {
58    #[serde(default)]
59    pub selected: Vec<AgentId>,
60}
61
62/// The `[settings]` section of the on-disk config. User-tunable knobs that
63/// don't fit naturally under `[agents]`, `[repo.*]`, or `[workspace.*]`.
64///
65/// All fields are optional; absent values fall back to either an env var
66/// (where one exists, e.g. `REPOGRAPH_PROJECT_ROOT` for `projects_root`) or
67/// to "ask the user next time they need it" semantics.
68#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Settings {
70    /// User-declared root folder for git projects (e.g. `~/IdeaProjects`,
71    /// `~/code`). When set, `repograph init`'s repo-registration step scans
72    /// this directly instead of probing the filesystem for common
73    /// conventions. `None` means "ask the user next time they need it" or
74    /// "fall back to free-form input with autocomplete."
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub projects_root: Option<PathBuf>,
77}
78
79/// Top-level config aggregating all registered repos, workspaces, and the
80/// user's agent selection.
81#[derive(Debug, Default, Clone, Serialize, Deserialize)]
82pub struct Config {
83    #[serde(default, rename = "repo", skip_serializing_if = "BTreeMap::is_empty")]
84    repos: BTreeMap<String, Repo>,
85    #[serde(
86        default,
87        rename = "workspace",
88        skip_serializing_if = "BTreeMap::is_empty"
89    )]
90    workspaces: BTreeMap<String, Workspace>,
91    /// User's agent toolchain selection. `None` means init has not been run;
92    /// `Some(Agents { selected: vec![] })` means init was run and the user
93    /// explicitly opted out of agent docs.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    agents: Option<Agents>,
96    /// Persistent user preferences (project root, …). Omitted from
97    /// serialization when empty.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    settings: Option<Settings>,
100}
101
102impl Config {
103    /// Read-only view of the registered repos.
104    #[must_use]
105    pub const fn repos(&self) -> &BTreeMap<String, Repo> {
106        &self.repos
107    }
108
109    /// Read-only view of the registered workspaces.
110    #[must_use]
111    pub const fn workspaces(&self) -> &BTreeMap<String, Workspace> {
112        &self.workspaces
113    }
114
115    /// Read-only view of the user's agent selection. Returns `None` when no
116    /// `[agents]` section is present (init has not been run); `Some(_)` when
117    /// init has run, even if `selected` is empty.
118    #[must_use]
119    pub const fn agents(&self) -> Option<&Agents> {
120        self.agents.as_ref()
121    }
122
123    /// Replace the `[agents]` section with the given selection. Passing
124    /// `Some(Agents { selected: vec![] })` writes a configured-but-empty
125    /// section; passing `None` removes the section (and signals "not
126    /// initialized" to consumers).
127    pub fn set_agents(&mut self, agents: Option<Agents>) {
128        self.agents = agents;
129    }
130
131    /// Read-only view of the user's persistent settings (project root,
132    /// future preferences). Returns `None` when no `[settings]` section is
133    /// present.
134    #[must_use]
135    pub const fn settings(&self) -> Option<&Settings> {
136        self.settings.as_ref()
137    }
138
139    /// Replace the `[settings]` section. Pass `None` to remove the section.
140    pub fn set_settings(&mut self, settings: Option<Settings>) {
141        self.settings = settings;
142    }
143
144    /// Platform-default config directory: `dirs::config_dir() / "repograph"`.
145    /// Returns `None` when no platform default exists (e.g. minimal envs); the
146    /// binary surfaces this as a usage error guiding the user to `--config-dir`.
147    #[must_use]
148    pub fn default_dir() -> Option<PathBuf> {
149        dirs::config_dir().map(|d| d.join("repograph"))
150    }
151
152    /// Load config from `dir/config.toml`. Missing file → empty `Config`.
153    /// Malformed TOML → `RepographError::ConfigParse`.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`RepographError::Io`] for filesystem failures, or
158    /// [`RepographError::ConfigParse`] when the file exists but is not valid TOML.
159    pub fn load(dir: &Path) -> Result<Self, RepographError> {
160        let path = dir.join(CONFIG_FILE_NAME);
161        match fs_err::read_to_string(&path) {
162            Ok(body) => Ok(toml::from_str(&body)?),
163            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
164            Err(e) => Err(e.into()),
165        }
166    }
167
168    /// Save config atomically to `dir/config.toml`, creating `dir` if missing.
169    ///
170    /// Atomicity: we serialize to a sibling temp file, then `rename` to the
171    /// target. A crash mid-write cannot leave the target half-written.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`RepographError::ConfigWrite`] when serialization fails,
176    /// [`RepographError::PermissionDenied`] when the target dir or file is not
177    /// writable, or [`RepographError::Io`] for other filesystem failures.
178    pub fn save(&self, dir: &Path) -> Result<(), RepographError> {
179        let body = toml::to_string_pretty(self)?;
180        let target = dir.join(CONFIG_FILE_NAME);
181
182        if let Err(e) = fs_err::create_dir_all(dir) {
183            return Err(map_io_to_perm(e, dir));
184        }
185
186        let tmp = dir.join(format!(".{CONFIG_FILE_NAME}.tmp"));
187        if let Err(e) = fs_err::write(&tmp, body.as_bytes()) {
188            return Err(map_io_to_perm(e, &tmp));
189        }
190        if let Err(e) = fs_err::rename(&tmp, &target) {
191            return Err(map_io_to_perm(e, &target));
192        }
193        Ok(())
194    }
195
196    /// Register a repo, enforcing both name and path uniqueness.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`RepographError::Conflict`] with `kind = "name"` when `name`
201    /// is already registered, or `kind = "path"` when `repo.path` is already
202    /// registered under a different name.
203    pub fn add_repo(&mut self, name: String, repo: Repo) -> Result<(), RepographError> {
204        if self.repos.contains_key(&name) {
205            return Err(RepographError::Conflict { kind: "name", name });
206        }
207        if let Some((existing_name, _)) = self.repos.iter().find(|(_, r)| r.path == repo.path) {
208            return Err(RepographError::Conflict {
209                kind: "path",
210                name: existing_name.clone(),
211            });
212        }
213        self.repos.insert(name, repo);
214        Ok(())
215    }
216
217    /// Deregister a repo by name.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`RepographError::NotFound`] when no repo by that name is registered.
222    pub fn remove_repo(&mut self, name: &str) -> Result<Repo, RepographError> {
223        self.repos
224            .remove(name)
225            .ok_or_else(|| RepographError::NotFound {
226                kind: "repo",
227                name: name.to_string(),
228            })
229    }
230
231    /// Create an empty workspace under `name` with an optional description.
232    ///
233    /// The name must satisfy [`validate_workspace_name`]. The workspace must
234    /// not already exist.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`RepographError::InvalidName`] when `name` violates the
239    /// naming policy, or [`RepographError::Conflict`] with `kind = "workspace"`
240    /// when a workspace by that name already exists.
241    pub fn create_workspace(
242        &mut self,
243        name: String,
244        description: Option<String>,
245    ) -> Result<(), RepographError> {
246        validate_workspace_name(&name)?;
247        if self.workspaces.contains_key(&name) {
248            return Err(RepographError::Conflict {
249                kind: "workspace",
250                name,
251            });
252        }
253        self.workspaces.insert(
254            name,
255            Workspace {
256                description: description.filter(|s| !s.is_empty()),
257                members: Vec::new(),
258            },
259        );
260        Ok(())
261    }
262
263    /// Delete a workspace by name. Registered repos are untouched.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
268    /// no workspace by that name is registered.
269    pub fn remove_workspace(&mut self, name: &str) -> Result<Workspace, RepographError> {
270        self.workspaces
271            .remove(name)
272            .ok_or_else(|| RepographError::NotFound {
273                kind: "workspace",
274                name: name.to_string(),
275            })
276    }
277
278    /// Atomically attach one or more registered repos to a workspace.
279    ///
280    /// All `repos` must be registered before any mutation occurs; if even one
281    /// is missing, the workspace is left unchanged. Already-member repos are
282    /// silently ignored. On success the `members` list is sorted and
283    /// deduplicated.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
288    /// the workspace does not exist, or `kind = "repo"` (naming the first
289    /// missing repo) when any input repo is not registered.
290    pub fn add_members(&mut self, workspace: &str, repos: &[String]) -> Result<(), RepographError> {
291        // Workspace presence first, so the error message names the right thing
292        // when neither workspace nor any of the repos exists.
293        if !self.workspaces.contains_key(workspace) {
294            return Err(RepographError::NotFound {
295                kind: "workspace",
296                name: workspace.to_string(),
297            });
298        }
299        for name in repos {
300            if !self.repos.contains_key(name) {
301                return Err(RepographError::NotFound {
302                    kind: "repo",
303                    name: name.clone(),
304                });
305            }
306        }
307        // Re-fetch as mutable; we re-emit NotFound rather than expect() so a
308        // future refactor that drops the contains_key guard can't introduce a panic.
309        let ws = self
310            .workspaces
311            .get_mut(workspace)
312            .ok_or_else(|| RepographError::NotFound {
313                kind: "workspace",
314                name: workspace.to_string(),
315            })?;
316        for name in repos {
317            ws.members.push(name.clone());
318        }
319        ws.members.sort();
320        ws.members.dedup();
321        Ok(())
322    }
323
324    /// Detach one or more repos from a workspace. Non-members are silently
325    /// ignored. The repo registry is not modified.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
330    /// the workspace does not exist.
331    pub fn remove_members(
332        &mut self,
333        workspace: &str,
334        repos: &[String],
335    ) -> Result<(), RepographError> {
336        let ws = self
337            .workspaces
338            .get_mut(workspace)
339            .ok_or_else(|| RepographError::NotFound {
340                kind: "workspace",
341                name: workspace.to_string(),
342            })?;
343        ws.members.retain(|m| !repos.iter().any(|r| r == m));
344        Ok(())
345    }
346
347    /// Walk a workspace's members and partition them into live entries
348    /// (resolved against the repo registry) and dangling names (tombstoned
349    /// references to repos that are no longer registered). The order in each
350    /// returned vector matches the workspace's stored `members` order
351    /// (alphabetical after sort-on-write).
352    ///
353    /// # Errors
354    ///
355    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
356    /// the workspace does not exist.
357    pub fn resolve_workspace<'a>(
358        &'a self,
359        workspace: &str,
360    ) -> Result<WorkspaceResolution<'a>, RepographError> {
361        let ws = self
362            .workspaces
363            .get(workspace)
364            .ok_or_else(|| RepographError::NotFound {
365                kind: "workspace",
366                name: workspace.to_string(),
367            })?;
368        let mut live = Vec::with_capacity(ws.members.len());
369        let mut dangling = Vec::new();
370        for name in &ws.members {
371            if let Some((key, repo)) = self.repos.get_key_value(name) {
372                live.push((key, repo));
373            } else {
374                dangling.push(name);
375            }
376        }
377        Ok((live, dangling))
378    }
379}
380
381/// Enforce the workspace naming policy: lowercase ASCII alphanumerics and
382/// hyphens, must start alphanumeric, length 1..=63, and not one of the
383/// reserved words.
384///
385/// # Errors
386///
387/// Returns [`RepographError::InvalidName`] with `kind = "workspace"` when the
388/// name violates the policy. The `reason` text is a short, user-facing phrase.
389pub fn validate_workspace_name(name: &str) -> Result<(), RepographError> {
390    if name.is_empty() {
391        return Err(invalid_workspace_name(name, "must not be empty"));
392    }
393    if name.len() > MAX_WORKSPACE_NAME_LEN {
394        return Err(invalid_workspace_name(
395            name,
396            "must be at most 63 characters",
397        ));
398    }
399    if RESERVED_WORKSPACE_NAMES.contains(&name) {
400        return Err(invalid_workspace_name(name, "is a reserved name"));
401    }
402    for (i, c) in name.chars().enumerate() {
403        let alnum_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
404        if i == 0 {
405            if !alnum_lower {
406                return Err(invalid_workspace_name(
407                    name,
408                    "must start with a lowercase letter or digit",
409                ));
410            }
411        } else if !alnum_lower && c != '-' {
412            return Err(invalid_workspace_name(
413                name,
414                "must contain only lowercase letters, digits, and hyphens",
415            ));
416        }
417    }
418    Ok(())
419}
420
421fn invalid_workspace_name(name: &str, reason: &'static str) -> RepographError {
422    RepographError::InvalidName {
423        kind: "workspace",
424        name: name.to_string(),
425        reason,
426    }
427}
428
429/// Map an [`std::io::Error`] to a typed permission-denied error when the kind
430/// matches; otherwise pass it through as `Io`.
431fn map_io_to_perm(e: std::io::Error, path: &Path) -> RepographError {
432    if e.kind() == std::io::ErrorKind::PermissionDenied {
433        RepographError::PermissionDenied {
434            path: path.to_path_buf(),
435        }
436    } else {
437        RepographError::Io(e)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    #![allow(clippy::unwrap_used, clippy::expect_used)]
444    use super::*;
445    use tempfile::TempDir;
446
447    fn make(path: &str) -> Repo {
448        Repo {
449            path: PathBuf::from(path),
450            description: None,
451            stack: vec![],
452        }
453    }
454
455    #[test]
456    fn load_missing_returns_empty() {
457        let tmp = TempDir::new().unwrap();
458        let cfg = Config::load(tmp.path()).unwrap();
459        assert!(cfg.repos.is_empty());
460    }
461
462    #[test]
463    fn save_then_load_round_trip() {
464        let tmp = TempDir::new().unwrap();
465        let mut cfg = Config::default();
466        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
467        cfg.add_repo(
468            "bar".into(),
469            Repo {
470                path: PathBuf::from("/tmp/bar"),
471                description: Some("hi".into()),
472                stack: vec!["rust".into()],
473            },
474        )
475        .unwrap();
476
477        cfg.save(tmp.path()).unwrap();
478        let loaded = Config::load(tmp.path()).unwrap();
479        assert_eq!(loaded.repos.len(), 2);
480        assert_eq!(
481            loaded.repos.get("bar").unwrap().description.as_deref(),
482            Some("hi")
483        );
484    }
485
486    #[test]
487    fn name_conflict_blocks_insert() {
488        let mut cfg = Config::default();
489        cfg.add_repo("foo".into(), make("/a")).unwrap();
490        let err = cfg.add_repo("foo".into(), make("/b")).unwrap_err();
491        assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
492        assert_eq!(cfg.repos.get("foo").unwrap().path, PathBuf::from("/a"));
493    }
494
495    #[test]
496    fn path_conflict_blocks_insert() {
497        let mut cfg = Config::default();
498        cfg.add_repo("foo".into(), make("/shared")).unwrap();
499        let err = cfg.add_repo("bar".into(), make("/shared")).unwrap_err();
500        assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
501        assert!(!cfg.repos.contains_key("bar"));
502    }
503
504    #[test]
505    fn remove_missing_returns_not_found() {
506        let mut cfg = Config::default();
507        let err = cfg.remove_repo("ghost").unwrap_err();
508        assert!(matches!(err, RepographError::NotFound { .. }));
509    }
510
511    #[test]
512    fn unknown_field_in_toml_is_tolerated() {
513        let tmp = TempDir::new().unwrap();
514        std::fs::create_dir_all(tmp.path()).unwrap();
515        std::fs::write(
516            tmp.path().join(CONFIG_FILE_NAME),
517            "[repo.foo]\npath = \"/tmp/foo\"\nfuture = \"yes\"\n",
518        )
519        .unwrap();
520        let cfg = Config::load(tmp.path()).unwrap();
521        assert!(cfg.repos.contains_key("foo"));
522    }
523
524    // --- Workspace tests ---
525
526    #[test]
527    fn validate_workspace_name_accepts_simple_lowercase() {
528        assert!(validate_workspace_name("acme").is_ok());
529        assert!(validate_workspace_name("acme-rebuild-2026").is_ok());
530        assert!(validate_workspace_name("a").is_ok());
531        assert!(validate_workspace_name("0").is_ok());
532        assert!(validate_workspace_name("0acme").is_ok());
533    }
534
535    #[test]
536    fn validate_workspace_name_rejects_empty() {
537        let err = validate_workspace_name("").unwrap_err();
538        assert!(matches!(err, RepographError::InvalidName { .. }));
539        assert_eq!(err.exit_code(), 2);
540    }
541
542    #[test]
543    fn validate_workspace_name_rejects_uppercase() {
544        let err = validate_workspace_name("AcmeRebuild").unwrap_err();
545        assert!(matches!(err, RepographError::InvalidName { .. }));
546    }
547
548    #[test]
549    fn validate_workspace_name_rejects_leading_hyphen() {
550        let err = validate_workspace_name("-acme").unwrap_err();
551        assert!(matches!(err, RepographError::InvalidName { .. }));
552    }
553
554    #[test]
555    fn validate_workspace_name_rejects_underscore() {
556        let err = validate_workspace_name("ac_me").unwrap_err();
557        assert!(matches!(err, RepographError::InvalidName { .. }));
558    }
559
560    #[test]
561    fn validate_workspace_name_rejects_spaces() {
562        let err = validate_workspace_name("ac me").unwrap_err();
563        assert!(matches!(err, RepographError::InvalidName { .. }));
564    }
565
566    #[test]
567    fn validate_workspace_name_rejects_overlength() {
568        let name = "a".repeat(MAX_WORKSPACE_NAME_LEN + 1);
569        let err = validate_workspace_name(&name).unwrap_err();
570        assert!(matches!(err, RepographError::InvalidName { .. }));
571    }
572
573    #[test]
574    fn validate_workspace_name_accepts_exact_max_length() {
575        let name = "a".repeat(MAX_WORKSPACE_NAME_LEN);
576        assert!(validate_workspace_name(&name).is_ok());
577    }
578
579    #[test]
580    fn validate_workspace_name_rejects_reserved_words() {
581        for reserved in RESERVED_WORKSPACE_NAMES {
582            let err = validate_workspace_name(reserved).unwrap_err();
583            assert!(
584                matches!(err, RepographError::InvalidName { .. }),
585                "reserved `{reserved}` must be rejected"
586            );
587        }
588    }
589
590    #[test]
591    fn create_workspace_inserts_empty_entry() {
592        let mut cfg = Config::default();
593        cfg.create_workspace("acme".into(), None).unwrap();
594        let ws = cfg.workspaces.get("acme").unwrap();
595        assert!(ws.description.is_none());
596        assert!(ws.members.is_empty());
597    }
598
599    #[test]
600    fn create_workspace_persists_description() {
601        let mut cfg = Config::default();
602        cfg.create_workspace("acme".into(), Some("rebuild".into()))
603            .unwrap();
604        assert_eq!(
605            cfg.workspaces.get("acme").unwrap().description.as_deref(),
606            Some("rebuild")
607        );
608    }
609
610    #[test]
611    fn create_workspace_conflict_returns_conflict() {
612        let mut cfg = Config::default();
613        cfg.create_workspace("acme".into(), None).unwrap();
614        let err = cfg.create_workspace("acme".into(), None).unwrap_err();
615        assert!(matches!(
616            err,
617            RepographError::Conflict {
618                kind: "workspace",
619                ..
620            }
621        ));
622        assert_eq!(err.exit_code(), 5);
623    }
624
625    #[test]
626    fn create_workspace_invalid_name_returns_invalid_name() {
627        let mut cfg = Config::default();
628        let err = cfg.create_workspace("Bad Name".into(), None).unwrap_err();
629        assert!(matches!(err, RepographError::InvalidName { .. }));
630        assert_eq!(err.exit_code(), 2);
631        assert!(cfg.workspaces.is_empty());
632    }
633
634    #[test]
635    fn remove_workspace_missing_returns_not_found() {
636        let mut cfg = Config::default();
637        let err = cfg.remove_workspace("ghost").unwrap_err();
638        assert!(matches!(
639            err,
640            RepographError::NotFound {
641                kind: "workspace",
642                ..
643            }
644        ));
645        assert_eq!(err.exit_code(), 3);
646    }
647
648    #[test]
649    fn remove_workspace_does_not_touch_repos() {
650        let mut cfg = Config::default();
651        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
652        cfg.create_workspace("acme".into(), None).unwrap();
653        cfg.add_members("acme", &["api".into()]).unwrap();
654        cfg.remove_workspace("acme").unwrap();
655        assert!(cfg.repos.contains_key("api"));
656        assert!(!cfg.workspaces.contains_key("acme"));
657    }
658
659    #[test]
660    fn add_members_atomic_when_one_repo_missing() {
661        let mut cfg = Config::default();
662        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
663        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
664        cfg.create_workspace("acme".into(), None).unwrap();
665        let err = cfg
666            .add_members("acme", &["api".into(), "ghost".into(), "ui".into()])
667            .unwrap_err();
668        assert!(matches!(
669            err,
670            RepographError::NotFound { kind: "repo", ref name } if name == "ghost"
671        ));
672        // No partial application.
673        assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
674    }
675
676    #[test]
677    fn add_members_sorts_and_deduplicates() {
678        let mut cfg = Config::default();
679        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
680        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
681        cfg.add_repo("libs".into(), make("/tmp/libs")).unwrap();
682        cfg.create_workspace("acme".into(), None).unwrap();
683        cfg.add_members("acme", &["ui".into(), "api".into(), "libs".into()])
684            .unwrap();
685        assert_eq!(
686            cfg.workspaces.get("acme").unwrap().members,
687            vec!["api", "libs", "ui"]
688        );
689        // Idempotent.
690        cfg.add_members("acme", &["api".into()]).unwrap();
691        assert_eq!(
692            cfg.workspaces.get("acme").unwrap().members,
693            vec!["api", "libs", "ui"]
694        );
695    }
696
697    #[test]
698    fn add_members_missing_workspace_returns_not_found() {
699        let mut cfg = Config::default();
700        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
701        let err = cfg.add_members("ghost", &["api".into()]).unwrap_err();
702        assert!(matches!(
703            err,
704            RepographError::NotFound {
705                kind: "workspace",
706                ..
707            }
708        ));
709    }
710
711    #[test]
712    fn remove_members_is_idempotent_for_non_members() {
713        let mut cfg = Config::default();
714        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
715        cfg.create_workspace("acme".into(), None).unwrap();
716        cfg.add_members("acme", &["api".into()]).unwrap();
717        cfg.remove_members("acme", &["ghost".into()]).unwrap();
718        assert_eq!(cfg.workspaces.get("acme").unwrap().members, vec!["api"]);
719    }
720
721    #[test]
722    fn remove_members_does_not_deregister_repo() {
723        let mut cfg = Config::default();
724        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
725        cfg.create_workspace("acme".into(), None).unwrap();
726        cfg.add_members("acme", &["api".into()]).unwrap();
727        cfg.remove_members("acme", &["api".into()]).unwrap();
728        assert!(cfg.repos.contains_key("api"));
729        assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
730    }
731
732    #[test]
733    fn resolve_workspace_partitions_live_and_dangling() {
734        let mut cfg = Config::default();
735        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
736        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
737        cfg.create_workspace("acme".into(), None).unwrap();
738        cfg.add_members("acme", &["api".into(), "ui".into()])
739            .unwrap();
740        // Tombstone: forcibly drop `ui` from the registry without touching the workspace.
741        cfg.remove_repo("ui").unwrap();
742        let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
743        assert_eq!(live.len(), 1);
744        assert_eq!(live[0].0, "api");
745        assert_eq!(dangling.len(), 1);
746        assert_eq!(dangling[0], "ui");
747    }
748
749    #[test]
750    fn resolve_workspace_recovers_after_reregistration() {
751        let mut cfg = Config::default();
752        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
753        cfg.create_workspace("acme".into(), None).unwrap();
754        cfg.add_members("acme", &["api".into()]).unwrap();
755        cfg.remove_repo("api").unwrap();
756        let (_, dangling) = cfg.resolve_workspace("acme").unwrap();
757        assert_eq!(dangling, vec!["api"]);
758        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
759        let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
760        assert_eq!(live.len(), 1);
761        assert!(dangling.is_empty());
762    }
763
764    #[test]
765    fn round_trip_with_mixed_repos_and_workspaces() {
766        let tmp = TempDir::new().unwrap();
767        let mut cfg = Config::default();
768        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
769        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
770        cfg.create_workspace("acme".into(), Some("Rebuild".into()))
771            .unwrap();
772        cfg.add_members("acme", &["ui".into(), "api".into()])
773            .unwrap();
774        cfg.create_workspace("billing".into(), None).unwrap();
775        cfg.save(tmp.path()).unwrap();
776
777        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
778        let loaded = Config::load(tmp.path()).unwrap();
779        loaded.save(tmp.path()).unwrap();
780        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
781        assert_eq!(body_first, body_second, "byte-identical round trip");
782
783        assert_eq!(loaded.workspaces.len(), 2);
784        let acme = loaded.workspaces.get("acme").unwrap();
785        assert_eq!(acme.description.as_deref(), Some("Rebuild"));
786        assert_eq!(acme.members, vec!["api", "ui"]);
787        let billing = loaded.workspaces.get("billing").unwrap();
788        assert!(billing.description.is_none());
789        assert!(billing.members.is_empty());
790    }
791
792    // --- Agents schema tests ---
793
794    #[test]
795    fn config_without_agents_section_loads_as_none() {
796        let tmp = TempDir::new().unwrap();
797        std::fs::create_dir_all(tmp.path()).unwrap();
798        std::fs::write(
799            tmp.path().join(CONFIG_FILE_NAME),
800            "[repo.foo]\npath = \"/tmp/foo\"\n",
801        )
802        .unwrap();
803        let cfg = Config::load(tmp.path()).unwrap();
804        assert!(cfg.agents().is_none());
805        assert!(cfg.repos.contains_key("foo"));
806    }
807
808    #[test]
809    fn config_with_empty_agents_is_some_with_empty_selection() {
810        let tmp = TempDir::new().unwrap();
811        std::fs::create_dir_all(tmp.path()).unwrap();
812        std::fs::write(
813            tmp.path().join(CONFIG_FILE_NAME),
814            "[agents]\nselected = []\n",
815        )
816        .unwrap();
817        let cfg = Config::load(tmp.path()).unwrap();
818        let agents = cfg.agents().expect("agents present");
819        assert!(agents.selected.is_empty());
820    }
821
822    #[test]
823    fn save_with_agents_none_omits_section() {
824        let tmp = TempDir::new().unwrap();
825        let mut cfg = Config::default();
826        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
827        // agents remains None.
828        cfg.save(tmp.path()).unwrap();
829        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
830        assert!(
831            !body.contains("[agents]"),
832            "no [agents] section when agents is None, got:\n{body}"
833        );
834    }
835
836    #[test]
837    fn save_with_empty_agents_writes_section_header() {
838        let tmp = TempDir::new().unwrap();
839        let mut cfg = Config::default();
840        cfg.set_agents(Some(Agents { selected: vec![] }));
841        cfg.save(tmp.path()).unwrap();
842        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
843        assert!(
844            body.contains("[agents]"),
845            "configured-but-empty still writes section header, got:\n{body}"
846        );
847    }
848
849    #[test]
850    fn agents_selection_order_round_trips() {
851        let tmp = TempDir::new().unwrap();
852        let mut cfg = Config::default();
853        cfg.set_agents(Some(Agents {
854            selected: vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd],
855        }));
856        cfg.save(tmp.path()).unwrap();
857        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
858        let cursor = body.find("\"cursor\"").expect("cursor present");
859        let claude = body.find("\"claude-code\"").expect("claude-code present");
860        let agents_md = body.find("\"agents-md\"").expect("agents-md present");
861        assert!(cursor < claude && claude < agents_md, "order preserved");
862
863        let reloaded = Config::load(tmp.path()).unwrap();
864        assert_eq!(
865            reloaded.agents().unwrap().selected,
866            vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd]
867        );
868    }
869
870    #[test]
871    fn agents_round_trip_with_repos_and_workspaces_is_byte_stable() {
872        let tmp = TempDir::new().unwrap();
873        let mut cfg = Config::default();
874        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
875        cfg.create_workspace("acme".into(), None).unwrap();
876        cfg.add_members("acme", &["api".into()]).unwrap();
877        cfg.set_agents(Some(Agents {
878            selected: vec![AgentId::ClaudeCode],
879        }));
880        cfg.save(tmp.path()).unwrap();
881        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
882
883        let loaded = Config::load(tmp.path()).unwrap();
884        loaded.save(tmp.path()).unwrap();
885        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
886        assert_eq!(
887            body_first, body_second,
888            "round-trip byte-identical with [agents]"
889        );
890    }
891
892    #[test]
893    fn unknown_agent_id_in_config_produces_parse_error() {
894        let tmp = TempDir::new().unwrap();
895        std::fs::create_dir_all(tmp.path()).unwrap();
896        std::fs::write(
897            tmp.path().join(CONFIG_FILE_NAME),
898            "[agents]\nselected = [\"claude-code\", \"bogus\"]\n",
899        )
900        .unwrap();
901        let err = Config::load(tmp.path()).unwrap_err();
902        assert!(
903            matches!(err, RepographError::ConfigParse(_)),
904            "expected ConfigParse, got {err:?}"
905        );
906        assert_eq!(err.exit_code(), 1);
907    }
908
909    // --- Settings schema tests ---
910
911    #[test]
912    fn config_without_settings_section_loads_as_none() {
913        let tmp = TempDir::new().unwrap();
914        std::fs::create_dir_all(tmp.path()).unwrap();
915        std::fs::write(
916            tmp.path().join(CONFIG_FILE_NAME),
917            "[agents]\nselected = []\n",
918        )
919        .unwrap();
920        let cfg = Config::load(tmp.path()).unwrap();
921        assert!(cfg.settings().is_none());
922    }
923
924    #[test]
925    fn save_with_settings_none_omits_section() {
926        let tmp = TempDir::new().unwrap();
927        let mut cfg = Config::default();
928        cfg.set_agents(Some(Agents {
929            selected: vec![AgentId::ClaudeCode],
930        }));
931        // settings remains None.
932        cfg.save(tmp.path()).unwrap();
933        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
934        assert!(
935            !body.contains("[settings]"),
936            "no [settings] section when settings is None, got:\n{body}"
937        );
938    }
939
940    #[test]
941    fn settings_projects_root_round_trip() {
942        let tmp = TempDir::new().unwrap();
943        let mut cfg = Config::default();
944        cfg.set_settings(Some(Settings {
945            projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
946        }));
947        cfg.save(tmp.path()).unwrap();
948        let reloaded = Config::load(tmp.path()).unwrap();
949        assert_eq!(
950            reloaded.settings().unwrap().projects_root.as_deref(),
951            Some(Path::new("/home/dev/IdeaProjects"))
952        );
953    }
954
955    #[test]
956    fn settings_with_none_projects_root_still_writes_section_header() {
957        let tmp = TempDir::new().unwrap();
958        let mut cfg = Config::default();
959        cfg.set_settings(Some(Settings::default()));
960        cfg.save(tmp.path()).unwrap();
961        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
962        assert!(
963            body.contains("[settings]"),
964            "configured-but-empty settings still writes header, got:\n{body}"
965        );
966        assert!(
967            !body.contains("projects_root"),
968            "absent field is omitted, got:\n{body}"
969        );
970    }
971
972    #[test]
973    fn settings_round_trip_with_agents_and_repos_is_byte_stable() {
974        let tmp = TempDir::new().unwrap();
975        let mut cfg = Config::default();
976        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
977        cfg.set_agents(Some(Agents {
978            selected: vec![AgentId::ClaudeCode],
979        }));
980        cfg.set_settings(Some(Settings {
981            projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
982        }));
983        cfg.save(tmp.path()).unwrap();
984        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
985
986        let loaded = Config::load(tmp.path()).unwrap();
987        loaded.save(tmp.path()).unwrap();
988        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
989        assert_eq!(
990            body_first, body_second,
991            "round-trip byte-identical with [settings]"
992        );
993    }
994
995    #[test]
996    fn unknown_field_on_workspace_is_tolerated() {
997        let tmp = TempDir::new().unwrap();
998        std::fs::create_dir_all(tmp.path()).unwrap();
999        std::fs::write(
1000            tmp.path().join(CONFIG_FILE_NAME),
1001            "[workspace.acme]\nmembers = []\nfuture = \"yes\"\n",
1002        )
1003        .unwrap();
1004        let cfg = Config::load(tmp.path()).unwrap();
1005        assert!(cfg.workspaces.contains_key("acme"));
1006    }
1007}