use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::agents::AgentId;
use crate::error::RepographError;
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const MAX_WORKSPACE_NAME_LEN: usize = 63;
pub const RESERVED_WORKSPACE_NAMES: &[&str] = &["default", "all", "none"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Repo {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stack: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct RepoEdit {
pub new_name: Option<String>,
pub description: Option<Option<String>>,
pub stack: Option<Vec<String>>,
pub path: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Workspace {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub members: Vec<String>,
}
pub type WorkspaceResolution<'a> = (Vec<(&'a String, &'a Repo)>, Vec<&'a String>);
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Agents {
#[serde(default)]
pub selected: Vec<AgentId>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projects_root: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default, rename = "repo", skip_serializing_if = "BTreeMap::is_empty")]
repos: BTreeMap<String, Repo>,
#[serde(
default,
rename = "workspace",
skip_serializing_if = "BTreeMap::is_empty"
)]
workspaces: BTreeMap<String, Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
agents: Option<Agents>,
#[serde(default, skip_serializing_if = "Option::is_none")]
settings: Option<Settings>,
}
impl Config {
#[must_use]
pub const fn repos(&self) -> &BTreeMap<String, Repo> {
&self.repos
}
#[must_use]
pub const fn workspaces(&self) -> &BTreeMap<String, Workspace> {
&self.workspaces
}
#[must_use]
pub const fn agents(&self) -> Option<&Agents> {
self.agents.as_ref()
}
pub fn set_agents(&mut self, agents: Option<Agents>) {
self.agents = agents;
}
#[must_use]
pub const fn settings(&self) -> Option<&Settings> {
self.settings.as_ref()
}
pub fn set_settings(&mut self, settings: Option<Settings>) {
self.settings = settings;
}
#[must_use]
pub fn default_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("repograph"))
}
pub fn load(dir: &Path) -> Result<Self, RepographError> {
let path = dir.join(CONFIG_FILE_NAME);
match fs_err::read_to_string(&path) {
Ok(body) => Ok(toml::from_str(&body)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(&self, dir: &Path) -> Result<(), RepographError> {
let body = toml::to_string_pretty(self)?;
let target = dir.join(CONFIG_FILE_NAME);
if let Err(e) = fs_err::create_dir_all(dir) {
return Err(map_io_to_perm(e, dir));
}
let tmp = dir.join(format!(".{CONFIG_FILE_NAME}.tmp"));
if let Err(e) = fs_err::write(&tmp, body.as_bytes()) {
return Err(map_io_to_perm(e, &tmp));
}
if let Err(e) = fs_err::rename(&tmp, &target) {
return Err(map_io_to_perm(e, &target));
}
Ok(())
}
pub fn add_repo(&mut self, name: String, repo: Repo) -> Result<(), RepographError> {
if self.repos.contains_key(&name) {
return Err(RepographError::Conflict { kind: "name", name });
}
if let Some((existing_name, _)) = self.repos.iter().find(|(_, r)| r.path == repo.path) {
return Err(RepographError::Conflict {
kind: "path",
name: existing_name.clone(),
});
}
self.repos.insert(name, repo);
Ok(())
}
pub fn remove_repo(&mut self, name: &str) -> Result<Repo, RepographError> {
self.repos
.remove(name)
.ok_or_else(|| RepographError::NotFound {
kind: "repo",
name: name.to_string(),
})
}
pub fn edit_repo(
&mut self,
name: &str,
edit: RepoEdit,
) -> Result<(String, Repo), RepographError> {
if !self.repos.contains_key(name) {
return Err(RepographError::NotFound {
kind: "repo",
name: name.to_string(),
});
}
let rename_to = edit
.new_name
.as_deref()
.filter(|n| *n != name)
.map(ToString::to_string);
if let Some(new_name) = &rename_to {
if self.repos.contains_key(new_name) {
return Err(RepographError::Conflict {
kind: "name",
name: new_name.clone(),
});
}
}
if let Some(new_path) = &edit.path {
if let Some((existing, _)) = self
.repos
.iter()
.find(|(k, r)| k.as_str() != name && &r.path == new_path)
{
return Err(RepographError::Conflict {
kind: "path",
name: existing.clone(),
});
}
}
let mut repo = self
.repos
.remove(name)
.ok_or_else(|| RepographError::NotFound {
kind: "repo",
name: name.to_string(),
})?;
if let Some(description) = edit.description {
repo.description = description.filter(|s| !s.is_empty());
}
if let Some(stack) = edit.stack {
repo.stack = stack;
}
if let Some(path) = edit.path {
repo.path = path;
}
let final_name = rename_to.clone().unwrap_or_else(|| name.to_string());
self.repos.insert(final_name.clone(), repo.clone());
if let Some(new_name) = &rename_to {
for ws in self.workspaces.values_mut() {
let mut touched = false;
for member in &mut ws.members {
if member == name {
member.clone_from(new_name);
touched = true;
}
}
if touched {
ws.members.sort();
ws.members.dedup();
}
}
}
Ok((final_name, repo))
}
pub fn create_workspace(
&mut self,
name: String,
description: Option<String>,
) -> Result<(), RepographError> {
validate_workspace_name(&name)?;
if self.workspaces.contains_key(&name) {
return Err(RepographError::Conflict {
kind: "workspace",
name,
});
}
self.workspaces.insert(
name,
Workspace {
description: description.filter(|s| !s.is_empty()),
members: Vec::new(),
},
);
Ok(())
}
pub fn remove_workspace(&mut self, name: &str) -> Result<Workspace, RepographError> {
self.workspaces
.remove(name)
.ok_or_else(|| RepographError::NotFound {
kind: "workspace",
name: name.to_string(),
})
}
pub fn add_members(&mut self, workspace: &str, repos: &[String]) -> Result<(), RepographError> {
if !self.workspaces.contains_key(workspace) {
return Err(RepographError::NotFound {
kind: "workspace",
name: workspace.to_string(),
});
}
for name in repos {
if !self.repos.contains_key(name) {
return Err(RepographError::NotFound {
kind: "repo",
name: name.clone(),
});
}
}
let ws = self
.workspaces
.get_mut(workspace)
.ok_or_else(|| RepographError::NotFound {
kind: "workspace",
name: workspace.to_string(),
})?;
for name in repos {
ws.members.push(name.clone());
}
ws.members.sort();
ws.members.dedup();
Ok(())
}
pub fn remove_members(
&mut self,
workspace: &str,
repos: &[String],
) -> Result<(), RepographError> {
let ws = self
.workspaces
.get_mut(workspace)
.ok_or_else(|| RepographError::NotFound {
kind: "workspace",
name: workspace.to_string(),
})?;
ws.members.retain(|m| !repos.iter().any(|r| r == m));
Ok(())
}
pub fn resolve_workspace<'a>(
&'a self,
workspace: &str,
) -> Result<WorkspaceResolution<'a>, RepographError> {
let ws = self
.workspaces
.get(workspace)
.ok_or_else(|| RepographError::NotFound {
kind: "workspace",
name: workspace.to_string(),
})?;
let mut live = Vec::with_capacity(ws.members.len());
let mut dangling = Vec::new();
for name in &ws.members {
if let Some((key, repo)) = self.repos.get_key_value(name) {
live.push((key, repo));
} else {
dangling.push(name);
}
}
Ok((live, dangling))
}
}
pub fn validate_workspace_name(name: &str) -> Result<(), RepographError> {
if name.is_empty() {
return Err(invalid_workspace_name(name, "must not be empty"));
}
if name.len() > MAX_WORKSPACE_NAME_LEN {
return Err(invalid_workspace_name(
name,
"must be at most 63 characters",
));
}
if RESERVED_WORKSPACE_NAMES.contains(&name) {
return Err(invalid_workspace_name(name, "is a reserved name"));
}
for (i, c) in name.chars().enumerate() {
let alnum_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
if i == 0 {
if !alnum_lower {
return Err(invalid_workspace_name(
name,
"must start with a lowercase letter or digit",
));
}
} else if !alnum_lower && c != '-' {
return Err(invalid_workspace_name(
name,
"must contain only lowercase letters, digits, and hyphens",
));
}
}
Ok(())
}
fn invalid_workspace_name(name: &str, reason: &'static str) -> RepographError {
RepographError::InvalidName {
kind: "workspace",
name: name.to_string(),
reason,
}
}
fn map_io_to_perm(e: std::io::Error, path: &Path) -> RepographError {
if e.kind() == std::io::ErrorKind::PermissionDenied {
RepographError::PermissionDenied {
path: path.to_path_buf(),
}
} else {
RepographError::Io(e)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use tempfile::TempDir;
fn make(path: &str) -> Repo {
Repo {
path: PathBuf::from(path),
description: None,
stack: vec![],
}
}
#[test]
fn load_missing_returns_empty() {
let tmp = TempDir::new().unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert!(cfg.repos.is_empty());
}
#[test]
fn save_then_load_round_trip() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
cfg.add_repo(
"bar".into(),
Repo {
path: PathBuf::from("/tmp/bar"),
description: Some("hi".into()),
stack: vec!["rust".into()],
},
)
.unwrap();
cfg.save(tmp.path()).unwrap();
let loaded = Config::load(tmp.path()).unwrap();
assert_eq!(loaded.repos.len(), 2);
assert_eq!(
loaded.repos.get("bar").unwrap().description.as_deref(),
Some("hi")
);
}
#[test]
fn name_conflict_blocks_insert() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/a")).unwrap();
let err = cfg.add_repo("foo".into(), make("/b")).unwrap_err();
assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
assert_eq!(cfg.repos.get("foo").unwrap().path, PathBuf::from("/a"));
}
#[test]
fn path_conflict_blocks_insert() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/shared")).unwrap();
let err = cfg.add_repo("bar".into(), make("/shared")).unwrap_err();
assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
assert!(!cfg.repos.contains_key("bar"));
}
#[test]
fn remove_missing_returns_not_found() {
let mut cfg = Config::default();
let err = cfg.remove_repo("ghost").unwrap_err();
assert!(matches!(err, RepographError::NotFound { .. }));
}
#[test]
fn unknown_field_in_toml_is_tolerated() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[repo.foo]\npath = \"/tmp/foo\"\nfuture = \"yes\"\n",
)
.unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert!(cfg.repos.contains_key("foo"));
}
#[test]
fn validate_workspace_name_accepts_simple_lowercase() {
assert!(validate_workspace_name("acme").is_ok());
assert!(validate_workspace_name("acme-rebuild-2026").is_ok());
assert!(validate_workspace_name("a").is_ok());
assert!(validate_workspace_name("0").is_ok());
assert!(validate_workspace_name("0acme").is_ok());
}
#[test]
fn validate_workspace_name_rejects_empty() {
let err = validate_workspace_name("").unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
assert_eq!(err.exit_code(), 2);
}
#[test]
fn validate_workspace_name_rejects_uppercase() {
let err = validate_workspace_name("AcmeRebuild").unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
}
#[test]
fn validate_workspace_name_rejects_leading_hyphen() {
let err = validate_workspace_name("-acme").unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
}
#[test]
fn validate_workspace_name_rejects_underscore() {
let err = validate_workspace_name("ac_me").unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
}
#[test]
fn validate_workspace_name_rejects_spaces() {
let err = validate_workspace_name("ac me").unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
}
#[test]
fn validate_workspace_name_rejects_overlength() {
let name = "a".repeat(MAX_WORKSPACE_NAME_LEN + 1);
let err = validate_workspace_name(&name).unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
}
#[test]
fn validate_workspace_name_accepts_exact_max_length() {
let name = "a".repeat(MAX_WORKSPACE_NAME_LEN);
assert!(validate_workspace_name(&name).is_ok());
}
#[test]
fn validate_workspace_name_rejects_reserved_words() {
for reserved in RESERVED_WORKSPACE_NAMES {
let err = validate_workspace_name(reserved).unwrap_err();
assert!(
matches!(err, RepographError::InvalidName { .. }),
"reserved `{reserved}` must be rejected"
);
}
}
#[test]
fn create_workspace_inserts_empty_entry() {
let mut cfg = Config::default();
cfg.create_workspace("acme".into(), None).unwrap();
let ws = cfg.workspaces.get("acme").unwrap();
assert!(ws.description.is_none());
assert!(ws.members.is_empty());
}
#[test]
fn create_workspace_persists_description() {
let mut cfg = Config::default();
cfg.create_workspace("acme".into(), Some("rebuild".into()))
.unwrap();
assert_eq!(
cfg.workspaces.get("acme").unwrap().description.as_deref(),
Some("rebuild")
);
}
#[test]
fn create_workspace_conflict_returns_conflict() {
let mut cfg = Config::default();
cfg.create_workspace("acme".into(), None).unwrap();
let err = cfg.create_workspace("acme".into(), None).unwrap_err();
assert!(matches!(
err,
RepographError::Conflict {
kind: "workspace",
..
}
));
assert_eq!(err.exit_code(), 5);
}
#[test]
fn create_workspace_invalid_name_returns_invalid_name() {
let mut cfg = Config::default();
let err = cfg.create_workspace("Bad Name".into(), None).unwrap_err();
assert!(matches!(err, RepographError::InvalidName { .. }));
assert_eq!(err.exit_code(), 2);
assert!(cfg.workspaces.is_empty());
}
#[test]
fn remove_workspace_missing_returns_not_found() {
let mut cfg = Config::default();
let err = cfg.remove_workspace("ghost").unwrap_err();
assert!(matches!(
err,
RepographError::NotFound {
kind: "workspace",
..
}
));
assert_eq!(err.exit_code(), 3);
}
#[test]
fn remove_workspace_does_not_touch_repos() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.remove_workspace("acme").unwrap();
assert!(cfg.repos.contains_key("api"));
assert!(!cfg.workspaces.contains_key("acme"));
}
#[test]
fn add_members_atomic_when_one_repo_missing() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
let err = cfg
.add_members("acme", &["api".into(), "ghost".into(), "ui".into()])
.unwrap_err();
assert!(matches!(
err,
RepographError::NotFound { kind: "repo", ref name } if name == "ghost"
));
assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
}
#[test]
fn add_members_sorts_and_deduplicates() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
cfg.add_repo("libs".into(), make("/tmp/libs")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["ui".into(), "api".into(), "libs".into()])
.unwrap();
assert_eq!(
cfg.workspaces.get("acme").unwrap().members,
vec!["api", "libs", "ui"]
);
cfg.add_members("acme", &["api".into()]).unwrap();
assert_eq!(
cfg.workspaces.get("acme").unwrap().members,
vec!["api", "libs", "ui"]
);
}
#[test]
fn add_members_missing_workspace_returns_not_found() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
let err = cfg.add_members("ghost", &["api".into()]).unwrap_err();
assert!(matches!(
err,
RepographError::NotFound {
kind: "workspace",
..
}
));
}
#[test]
fn remove_members_is_idempotent_for_non_members() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.remove_members("acme", &["ghost".into()]).unwrap();
assert_eq!(cfg.workspaces.get("acme").unwrap().members, vec!["api"]);
}
#[test]
fn remove_members_does_not_deregister_repo() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.remove_members("acme", &["api".into()]).unwrap();
assert!(cfg.repos.contains_key("api"));
assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
}
#[test]
fn resolve_workspace_partitions_live_and_dangling() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into(), "ui".into()])
.unwrap();
cfg.remove_repo("ui").unwrap();
let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].0, "api");
assert_eq!(dangling.len(), 1);
assert_eq!(dangling[0], "ui");
}
#[test]
fn resolve_workspace_recovers_after_reregistration() {
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.remove_repo("api").unwrap();
let (_, dangling) = cfg.resolve_workspace("acme").unwrap();
assert_eq!(dangling, vec!["api"]);
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
assert_eq!(live.len(), 1);
assert!(dangling.is_empty());
}
#[test]
fn round_trip_with_mixed_repos_and_workspaces() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
cfg.create_workspace("acme".into(), Some("Rebuild".into()))
.unwrap();
cfg.add_members("acme", &["ui".into(), "api".into()])
.unwrap();
cfg.create_workspace("billing".into(), None).unwrap();
cfg.save(tmp.path()).unwrap();
let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
let loaded = Config::load(tmp.path()).unwrap();
loaded.save(tmp.path()).unwrap();
let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert_eq!(body_first, body_second, "byte-identical round trip");
assert_eq!(loaded.workspaces.len(), 2);
let acme = loaded.workspaces.get("acme").unwrap();
assert_eq!(acme.description.as_deref(), Some("Rebuild"));
assert_eq!(acme.members, vec!["api", "ui"]);
let billing = loaded.workspaces.get("billing").unwrap();
assert!(billing.description.is_none());
assert!(billing.members.is_empty());
}
#[test]
fn config_without_agents_section_loads_as_none() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[repo.foo]\npath = \"/tmp/foo\"\n",
)
.unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert!(cfg.agents().is_none());
assert!(cfg.repos.contains_key("foo"));
}
#[test]
fn config_with_empty_agents_is_some_with_empty_selection() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[agents]\nselected = []\n",
)
.unwrap();
let cfg = Config::load(tmp.path()).unwrap();
let agents = cfg.agents().expect("agents present");
assert!(agents.selected.is_empty());
}
#[test]
fn save_with_agents_none_omits_section() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
cfg.save(tmp.path()).unwrap();
let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert!(
!body.contains("[agents]"),
"no [agents] section when agents is None, got:\n{body}"
);
}
#[test]
fn save_with_empty_agents_writes_section_header() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_agents(Some(Agents { selected: vec![] }));
cfg.save(tmp.path()).unwrap();
let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert!(
body.contains("[agents]"),
"configured-but-empty still writes section header, got:\n{body}"
);
}
#[test]
fn agents_selection_order_round_trips() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd],
}));
cfg.save(tmp.path()).unwrap();
let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
let cursor = body.find("\"cursor\"").expect("cursor present");
let claude = body.find("\"claude-code\"").expect("claude-code present");
let agents_md = body.find("\"agents-md\"").expect("agents-md present");
assert!(cursor < claude && claude < agents_md, "order preserved");
let reloaded = Config::load(tmp.path()).unwrap();
assert_eq!(
reloaded.agents().unwrap().selected,
vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd]
);
}
#[test]
fn agents_round_trip_with_repos_and_workspaces_is_byte_stable() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["api".into()]).unwrap();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::ClaudeCode],
}));
cfg.save(tmp.path()).unwrap();
let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
let loaded = Config::load(tmp.path()).unwrap();
loaded.save(tmp.path()).unwrap();
let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert_eq!(
body_first, body_second,
"round-trip byte-identical with [agents]"
);
}
#[test]
fn unknown_agent_id_in_config_produces_parse_error() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[agents]\nselected = [\"claude-code\", \"bogus\"]\n",
)
.unwrap();
let err = Config::load(tmp.path()).unwrap_err();
assert!(
matches!(err, RepographError::ConfigParse(_)),
"expected ConfigParse, got {err:?}"
);
assert_eq!(err.exit_code(), 1);
}
#[test]
fn config_without_settings_section_loads_as_none() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[agents]\nselected = []\n",
)
.unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert!(cfg.settings().is_none());
}
#[test]
fn save_with_settings_none_omits_section() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::ClaudeCode],
}));
cfg.save(tmp.path()).unwrap();
let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert!(
!body.contains("[settings]"),
"no [settings] section when settings is None, got:\n{body}"
);
}
#[test]
fn settings_projects_root_round_trip() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_settings(Some(Settings {
projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
}));
cfg.save(tmp.path()).unwrap();
let reloaded = Config::load(tmp.path()).unwrap();
assert_eq!(
reloaded.settings().unwrap().projects_root.as_deref(),
Some(Path::new("/home/dev/IdeaProjects"))
);
}
#[test]
fn settings_with_none_projects_root_still_writes_section_header() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.set_settings(Some(Settings::default()));
cfg.save(tmp.path()).unwrap();
let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert!(
body.contains("[settings]"),
"configured-but-empty settings still writes header, got:\n{body}"
);
assert!(
!body.contains("projects_root"),
"absent field is omitted, got:\n{body}"
);
}
#[test]
fn settings_round_trip_with_agents_and_repos_is_byte_stable() {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
cfg.set_agents(Some(Agents {
selected: vec![AgentId::ClaudeCode],
}));
cfg.set_settings(Some(Settings {
projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
}));
cfg.save(tmp.path()).unwrap();
let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
let loaded = Config::load(tmp.path()).unwrap();
loaded.save(tmp.path()).unwrap();
let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
assert_eq!(
body_first, body_second,
"round-trip byte-identical with [settings]"
);
}
#[test]
fn unknown_field_on_workspace_is_tolerated() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(CONFIG_FILE_NAME),
"[workspace.acme]\nmembers = []\nfuture = \"yes\"\n",
)
.unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert!(cfg.workspaces.contains_key("acme"));
}
#[test]
fn edit_repo_updates_description_and_stack_in_place() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
let (name, repo) = cfg
.edit_repo(
"foo",
RepoEdit {
description: Some(Some("new".into())),
stack: Some(vec!["rust".into(), "cli".into()]),
..RepoEdit::default()
},
)
.unwrap();
assert_eq!(name, "foo");
assert_eq!(repo.description.as_deref(), Some("new"));
assert_eq!(repo.stack, vec!["rust", "cli"]);
assert_eq!(
cfg.repos.get("foo").unwrap().path,
PathBuf::from("/tmp/foo")
);
}
#[test]
fn edit_repo_empty_description_clears_it() {
let mut cfg = Config::default();
cfg.add_repo(
"foo".into(),
Repo {
path: PathBuf::from("/tmp/foo"),
description: Some("old".into()),
stack: vec![],
},
)
.unwrap();
cfg.edit_repo(
"foo",
RepoEdit {
description: Some(None),
..RepoEdit::default()
},
)
.unwrap();
assert!(cfg.repos.get("foo").unwrap().description.is_none());
}
#[test]
fn edit_repo_rename_preserves_workspace_membership() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
cfg.create_workspace("acme".into(), None).unwrap();
cfg.add_members("acme", &["foo".into()]).unwrap();
let (name, _) = cfg
.edit_repo(
"foo",
RepoEdit {
new_name: Some("bar".into()),
..RepoEdit::default()
},
)
.unwrap();
assert_eq!(name, "bar");
assert!(cfg.repos.contains_key("bar"));
assert!(!cfg.repos.contains_key("foo"));
let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
assert!(dangling.is_empty(), "rename left a dangling member");
assert_eq!(live.len(), 1);
assert_eq!(live[0].0, "bar");
}
#[test]
fn edit_repo_rename_to_existing_name_conflicts() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
let err = cfg
.edit_repo(
"foo",
RepoEdit {
new_name: Some("bar".into()),
..RepoEdit::default()
},
)
.unwrap_err();
assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
assert!(cfg.repos.contains_key("foo"));
assert_eq!(
cfg.repos.get("bar").unwrap().path,
PathBuf::from("/tmp/bar")
);
}
#[test]
fn edit_repo_nonexistent_returns_not_found() {
let mut cfg = Config::default();
let err = cfg
.edit_repo(
"ghost",
RepoEdit {
description: Some(Some("x".into())),
..RepoEdit::default()
},
)
.unwrap_err();
assert!(matches!(err, RepographError::NotFound { kind: "repo", .. }));
}
#[test]
fn edit_repo_path_conflict_returns_conflict() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
let err = cfg
.edit_repo(
"foo",
RepoEdit {
path: Some(PathBuf::from("/tmp/bar")),
..RepoEdit::default()
},
)
.unwrap_err();
assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
assert_eq!(
cfg.repos.get("foo").unwrap().path,
PathBuf::from("/tmp/foo")
);
}
#[test]
fn edit_repo_rename_to_same_name_is_noop_not_conflict() {
let mut cfg = Config::default();
cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
let (name, _) = cfg
.edit_repo(
"foo",
RepoEdit {
new_name: Some("foo".into()),
description: Some(Some("d".into())),
..RepoEdit::default()
},
)
.unwrap();
assert_eq!(name, "foo");
assert_eq!(
cfg.repos.get("foo").unwrap().description.as_deref(),
Some("d")
);
}
}