1use std::collections::BTreeMap;
11use std::ffi::OsString;
12use std::fs::{self, File, OpenOptions};
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use tempfile::NamedTempFile;
19
20use crate::error::{Error, Result};
21use crate::name::{AgentId, Platform, ProfileName};
22
23pub const HOME_ENV: &str = "AGENT_PROFILE_HOME";
25
26const CONFIG_FILE: &str = "config.toml";
27const LOCK_FILE: &str = "config.toml.lock";
28const TEMP_PREFIX: &str = ".config.toml.";
29const TEMP_SUFFIX: &str = ".tmp";
30const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
31const LOCK_RETRY: Duration = Duration::from_millis(50);
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AppRoot(PathBuf);
36
37impl AppRoot {
38 pub fn from_path(path: PathBuf) -> AppRoot {
40 AppRoot(path)
41 }
42
43 pub fn resolve() -> Result<AppRoot> {
45 AppRoot::resolve_from(std::env::var_os(HOME_ENV), std::env::home_dir())
46 }
47
48 fn resolve_from(home_override: Option<OsString>, home_dir: Option<PathBuf>) -> Result<AppRoot> {
49 match home_override {
50 Some(value) => {
51 let path = PathBuf::from(value);
52 if path.as_os_str().is_empty() || !path.is_absolute() {
53 return Err(Error::AppRoot {
54 message: format!(
55 "{HOME_ENV} must be an absolute path, got {:?}",
56 path.as_os_str()
57 ),
58 });
59 }
60 Ok(AppRoot(path))
61 }
62 None => match home_dir {
63 Some(home) if home.is_absolute() => Ok(AppRoot(home.join(".agent-profile"))),
64 _ => Err(Error::AppRoot {
65 message: format!(
66 "cannot determine an absolute home directory; set {HOME_ENV} to an absolute path"
67 ),
68 }),
69 },
70 }
71 }
72
73 pub fn path(&self) -> &Path {
74 &self.0
75 }
76
77 pub fn config_path(&self) -> PathBuf {
78 self.0.join(CONFIG_FILE)
79 }
80
81 pub fn profiles_dir(&self) -> PathBuf {
82 self.0.join("profiles")
83 }
84
85 fn lock_path(&self) -> PathBuf {
86 self.0.join(LOCK_FILE)
87 }
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct Config {
93 agents: BTreeMap<String, Option<PathBuf>>,
94 default_profile: Option<ProfileName>,
95 repositories: BTreeMap<String, Mapping>,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Mapping {
102 pub profile: Option<ProfileName>,
103 pub agents: BTreeMap<AgentId, ProfileName>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Reference {
109 pub root: PathBuf,
111 pub agent: Option<AgentId>,
113 pub profile: ProfileName,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum LinkOutcome {
120 Linked,
121 Changed { old: ProfileName },
122 AlreadyLinked,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum UnlinkOutcome {
128 Unlinked { root: PathBuf, old: ProfileName },
130 NothingToRemove { shown: PathBuf },
132}
133
134impl Config {
135 pub fn load(root: &AppRoot) -> Result<Config> {
137 let path = root.config_path();
138 match fs::read(&path) {
139 Ok(bytes) => parse(&path, &bytes),
140 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Config::default()),
141 Err(error) => Err(invalid(&path, None, format!("cannot read the file: {error}"))),
142 }
143 }
144
145 pub fn agent_executable(&self, id: &str) -> Option<&Path> {
147 self.agents.get(id).and_then(|executable| executable.as_deref())
148 }
149
150 pub fn configured_agents(&self) -> impl Iterator<Item = &str> {
152 self.agents.keys().map(String::as_str)
153 }
154
155 pub fn default_profile(&self) -> Option<&ProfileName> {
157 self.default_profile.as_ref()
158 }
159
160 pub fn mapping(&self, root: &Path) -> Option<&Mapping> {
162 self.mappings().find(|(key, _)| *key == root).map(|(_, mapping)| mapping)
163 }
164
165 pub fn mappings(&self) -> impl Iterator<Item = (&Path, &Mapping)> {
167 self.repositories.iter().map(|(key, mapping)| (Path::new(key.as_str()), mapping))
168 }
169
170 pub fn mappings_referencing(&self, profile: &ProfileName) -> Vec<Reference> {
172 let matches = |name: &ProfileName| name.as_str().eq_ignore_ascii_case(profile.as_str());
173 let mut references = Vec::new();
174 for (root, mapping) in self.mappings() {
175 if let Some(name) = mapping.profile.as_ref().filter(|name| matches(name)) {
176 references.push(Reference {
177 root: root.to_path_buf(),
178 agent: None,
179 profile: name.clone(),
180 });
181 }
182 for (agent, name) in mapping.agents.iter().filter(|(_, name)| matches(name)) {
183 references.push(Reference {
184 root: root.to_path_buf(),
185 agent: Some(agent.clone()),
186 profile: name.clone(),
187 });
188 }
189 }
190 references
191 }
192}
193
194fn invalid(path: &Path, key: Option<String>, detail: String) -> Error {
195 Error::ConfigInvalid { path: path.to_path_buf(), key, detail }
196}
197
198fn parse(path: &Path, bytes: &[u8]) -> Result<Config> {
199 let text = std::str::from_utf8(bytes)
200 .map_err(|_| invalid(path, None, "the file is not valid UTF-8".to_owned()))?;
201 let table: toml::Table = text.parse().map_err(|error: toml::de::Error| {
202 let line = error.span().map(|span| text[..span.start].matches('\n').count() + 1);
203 let detail = match line {
204 Some(line) => format!("TOML syntax error at line {line}: {}", error.message()),
205 None => format!("TOML syntax error: {}", error.message()),
206 };
207 invalid(path, None, detail)
208 })?;
209 validate(path, &table)
210}
211
212fn validate(path: &Path, table: &toml::Table) -> Result<Config> {
214 let mut config = Config::default();
215 for (key, value) in table {
216 match key.as_str() {
217 "agents" => validate_agents(path, key, value, &mut config)?,
218 "default_profile" => {
219 let name = value.as_str().ok_or_else(|| {
220 invalid(path, Some(key.clone()), "must be a string".to_owned())
221 })?;
222 config.default_profile = Some(profile_name(path, key, name)?);
223 }
224 "repositories" => validate_repositories(path, key, value, &mut config)?,
225 _ => return Err(invalid(path, Some(key.clone()), "unknown key".to_owned())),
226 }
227 }
228 Ok(config)
229}
230
231fn profile_name(path: &Path, key: &str, name: &str) -> Result<ProfileName> {
232 ProfileName::parse(name, Platform::host()).map_err(|reason| {
233 invalid(path, Some(key.to_owned()), format!("invalid profile name {name:?}: {reason}"))
234 })
235}
236
237fn repository_key(root: &str) -> String {
239 format!("repositories.{}", toml_edit::Key::new(root).display_repr())
240}
241
242fn is_absolute_key(root: &str) -> bool {
244 let bytes = root.as_bytes();
245 let drive = bytes.len() >= 3
246 && bytes[0].is_ascii_alphabetic()
247 && bytes[1] == b':'
248 && matches!(bytes[2], b'\\' | b'/');
249 root.starts_with('/') || root.starts_with(r"\\") || drive
250}
251
252pub fn is_host_form_key(root: &str) -> bool {
255 if cfg!(windows) { !root.starts_with('/') } else { root.starts_with('/') }
256}
257
258fn validate_repositories(
259 path: &Path,
260 key: &str,
261 value: &toml::Value,
262 config: &mut Config,
263) -> Result<()> {
264 let repositories = value
265 .as_table()
266 .ok_or_else(|| invalid(path, Some(key.to_owned()), "must be a table".to_owned()))?;
267 for (root, entry) in repositories {
268 let entry_key = repository_key(root);
269 if !is_absolute_key(root) {
270 return Err(invalid(path, Some(entry_key), "must be an absolute path".to_owned()));
271 }
272 let entry = entry
273 .as_table()
274 .ok_or_else(|| invalid(path, Some(entry_key.clone()), "must be a table".to_owned()))?;
275 let mut mapping = Mapping::default();
276 for (field, value) in entry {
277 let field_key = format!("{entry_key}.{field}");
278 match field.as_str() {
279 "profile" => {
280 let name = value.as_str().ok_or_else(|| {
281 invalid(path, Some(field_key.clone()), "must be a string".to_owned())
282 })?;
283 mapping.profile = Some(profile_name(path, &field_key, name)?);
284 }
285 "agents" => {
286 let agents = value.as_table().ok_or_else(|| {
287 invalid(path, Some(field_key.clone()), "must be a table".to_owned())
288 })?;
289 for (id, name) in agents {
290 let agent_key = format!("{field_key}.{id}");
291 let Some(agent) = AgentId::parse(id) else {
292 return Err(invalid(
293 path,
294 Some(agent_key),
295 "agent ids must match [a-z][a-z0-9-]*".to_owned(),
296 ));
297 };
298 let name = name.as_str().ok_or_else(|| {
299 invalid(path, Some(agent_key.clone()), "must be a string".to_owned())
300 })?;
301 mapping.agents.insert(agent, profile_name(path, &agent_key, name)?);
302 }
303 }
304 _ => return Err(invalid(path, Some(field_key), "unknown key".to_owned())),
305 }
306 }
307 config.repositories.insert(root.clone(), mapping);
308 }
309 let roots: Vec<&String> = config.repositories.keys().collect();
310 for (index, first) in roots.iter().enumerate() {
311 if let Some(second) =
312 roots[index + 1..].iter().find(|second| Path::new(second) == Path::new(first))
313 {
314 return Err(invalid(
315 path,
316 Some(repository_key(first)),
317 format!("names the same directory as {}", repository_key(second)),
318 ));
319 }
320 }
321 Ok(())
322}
323
324fn validate_agents(path: &Path, key: &str, value: &toml::Value, config: &mut Config) -> Result<()> {
325 let agents = value
326 .as_table()
327 .ok_or_else(|| invalid(path, Some(key.to_owned()), "must be a table".to_owned()))?;
328 for (id, agent) in agents {
329 let agent_key = format!("agents.{id}");
330 if AgentId::parse(id).is_none() {
331 return Err(invalid(
332 path,
333 Some(agent_key),
334 "agent ids must match [a-z][a-z0-9-]*".to_owned(),
335 ));
336 }
337 let agent = agent
338 .as_table()
339 .ok_or_else(|| invalid(path, Some(agent_key.clone()), "must be a table".to_owned()))?;
340 let mut executable = None;
341 for (field, value) in agent {
342 let field_key = format!("{agent_key}.{field}");
343 if field != "executable" {
344 return Err(invalid(path, Some(field_key), "unknown key".to_owned()));
345 }
346 let text = value.as_str().ok_or_else(|| {
347 invalid(path, Some(field_key.clone()), "must be a string".to_owned())
348 })?;
349 let candidate = PathBuf::from(text);
350 if !candidate.is_absolute() {
351 return Err(invalid(path, Some(field_key), "must be an absolute path".to_owned()));
352 }
353 executable = Some(candidate);
354 }
355 config.agents.insert(id.clone(), executable);
356 }
357 Ok(())
358}
359
360pub fn update(
362 root: &AppRoot,
363 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<()>,
364) -> Result<()> {
365 update_with(
366 root,
367 edit,
368 || Ok(()),
369 |temp, destination| temp.persist(destination).map(drop).map_err(|error| error.error),
370 )
371}
372
373pub(crate) fn update_with(
374 root: &AppRoot,
375 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<()>,
376 before_persist: impl FnOnce() -> Result<()>,
377 replace: impl FnOnce(NamedTempFile, &Path) -> io::Result<()>,
378) -> Result<()> {
379 let config_path = root.config_path();
380 let write_error =
381 |path: &Path, source: io::Error| Error::ConfigWrite { path: path.to_path_buf(), source };
382
383 fs::create_dir_all(root.path()).map_err(|source| write_error(root.path(), source))?;
385
386 let lock_path = root.lock_path();
388 let lock = OpenOptions::new()
389 .read(true)
390 .write(true)
391 .create(true)
392 .truncate(false)
393 .open(&lock_path)
394 .map_err(|source| write_error(&lock_path, source))?;
395 acquire(&lock, &lock_path)?;
396
397 if let Ok(entries) = fs::read_dir(root.path()) {
399 for entry in entries.flatten() {
400 let name = entry.file_name();
401 let name = name.to_string_lossy();
402 if name.starts_with(TEMP_PREFIX) && name.ends_with(TEMP_SUFFIX) {
403 let _ = fs::remove_file(entry.path());
404 }
405 }
406 }
407
408 let text = match fs::read(&config_path) {
410 Ok(bytes) => {
411 parse(&config_path, &bytes)?;
412 String::from_utf8(bytes).expect("parse checked UTF-8")
413 }
414 Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
415 Err(error) => {
416 return Err(invalid(&config_path, None, format!("cannot read the file: {error}")));
417 }
418 };
419 let mut document: toml_edit::DocumentMut = text
420 .parse()
421 .map_err(|error| invalid(&config_path, None, format!("TOML syntax error: {error}")))?;
422
423 let unedited = document.to_string();
426 edit(&mut document)?;
427 let updated = document.to_string();
428 if updated == unedited {
429 return Ok(());
430 }
431 parse(&config_path, updated.as_bytes())?;
432
433 let mut temp = tempfile::Builder::new()
435 .prefix(TEMP_PREFIX)
436 .suffix(TEMP_SUFFIX)
437 .tempfile_in(root.path())
438 .map_err(|source| write_error(&config_path, source))?;
439 temp.write_all(updated.as_bytes()).map_err(|source| write_error(&config_path, source))?;
440 temp.as_file().sync_all().map_err(|source| write_error(&config_path, source))?;
441 before_persist()?;
442
443 replace(temp, &config_path).map_err(|source| write_error(&config_path, source))?;
445
446 #[cfg(unix)]
448 File::open(root.path()).and_then(|directory| directory.sync_all()).map_err(|error| {
449 write_error(
450 &config_path,
451 io::Error::new(
452 error.kind(),
453 format!(
454 "configuration replaced, but the directory could not be synced; the change may \
455 not survive a power loss: {error}"
456 ),
457 ),
458 )
459 })?;
460
461 drop(lock);
463 Ok(())
464}
465
466pub fn locked<T>(root: &AppRoot, f: impl FnOnce(&Config) -> Result<T>) -> Result<T> {
469 let lock_path = root.lock_path();
470 let write_error =
471 |path: &Path, source: io::Error| Error::ConfigWrite { path: path.to_path_buf(), source };
472
473 let lock = OpenOptions::new()
474 .read(true)
475 .write(true)
476 .create(true)
477 .truncate(false)
478 .open(&lock_path)
479 .map_err(|source| write_error(&lock_path, source))?;
480 acquire(&lock, &lock_path)?;
481
482 let config = Config::load(root)?;
483 f(&config)
484}
485
486pub fn link(
489 root: &AppRoot,
490 repository: &Path,
491 agent: Option<&AgentId>,
492 profile: &ProfileName,
493) -> Result<LinkOutcome> {
494 let Some(key) = repository.to_str() else {
495 return Err(Error::Repository {
496 path: repository.to_path_buf(),
497 reason: "a repository path that is not valid UTF-8 cannot be linked".to_owned(),
498 });
499 };
500 check_case_twins(root, profile)?;
501 let mut outcome = LinkOutcome::Linked;
502 update(root, |document| {
503 let stored = stored_keys(document)
504 .into_iter()
505 .find(|stored| Path::new(stored) == repository)
506 .unwrap_or_else(|| key.to_owned());
507 let repositories = child_table(document.as_table_mut(), "repositories", false);
508 let entry = child_table(repositories, &stored, false);
509 outcome = link_outcome(mapped(entry, agent), profile);
510 if outcome == LinkOutcome::AlreadyLinked {
511 return Ok(());
512 }
513 let value = toml_edit::value(profile.as_str());
514 match agent {
515 None => entry.insert("profile", value),
516 Some(agent) => child_table(entry, "agents", true).insert(agent.as_str(), value),
517 };
518 Ok(())
519 })?;
520 Ok(outcome)
521}
522
523fn link_outcome(current: Option<String>, profile: &ProfileName) -> LinkOutcome {
524 match current {
525 None => LinkOutcome::Linked,
526 Some(current) if current == profile.as_str() => LinkOutcome::AlreadyLinked,
527 Some(current) => LinkOutcome::Changed {
528 old: ProfileName::parse(¤t, Platform::host()).expect("validated profile name"),
529 },
530 }
531}
532
533pub fn unlink(root: &AppRoot, keys: &[PathBuf], agent: Option<&AgentId>) -> Result<UnlinkOutcome> {
536 let mut outcome = UnlinkOutcome::NothingToRemove { shown: keys[0].clone() };
537 update(root, |document| {
538 let Some(repositories) =
539 document.get_mut("repositories").and_then(toml_edit::Item::as_table_like_mut)
540 else {
541 return Ok(());
542 };
543 let found = keys.iter().find_map(|key| {
544 repositories.iter().find_map(|(stored, entry)| {
545 let entry = entry.as_table_like()?;
546 (Path::new(stored) == key)
547 .then(|| mapped(entry, agent))
548 .flatten()
549 .map(|old| (stored.to_owned(), old))
550 })
551 });
552 let Some((stored, old)) = found else {
553 return Ok(());
554 };
555 let entry = repositories
556 .get_mut(&stored)
557 .and_then(toml_edit::Item::as_table_like_mut)
558 .expect("validated entry");
559 match agent {
560 None => {
561 entry.remove("profile");
562 }
563 Some(agent) => {
564 let agents = entry
565 .get_mut("agents")
566 .and_then(toml_edit::Item::as_table_like_mut)
567 .expect("validated agents table");
568 agents.remove(agent.as_str());
569 if agents.is_empty() {
570 entry.remove("agents");
571 }
572 }
573 }
574 if entry.is_empty() {
575 repositories.remove(&stored);
576 }
577 outcome = UnlinkOutcome::Unlinked {
578 root: PathBuf::from(stored),
579 old: ProfileName::parse(&old, Platform::host()).expect("validated profile name"),
580 };
581 Ok(())
582 })?;
583 Ok(outcome)
584}
585
586fn stored_keys(document: &toml_edit::DocumentMut) -> Vec<String> {
588 document
589 .get("repositories")
590 .and_then(toml_edit::Item::as_table_like)
591 .map(|repositories| repositories.iter().map(|(key, _)| key.to_owned()).collect())
592 .unwrap_or_default()
593}
594
595fn mapped(entry: &dyn toml_edit::TableLike, agent: Option<&AgentId>) -> Option<String> {
597 let value = match agent {
598 None => entry.get("profile"),
599 Some(agent) => entry
600 .get("agents")
601 .and_then(toml_edit::Item::as_table_like)
602 .and_then(|agents| agents.get(agent.as_str())),
603 };
604 value.and_then(toml_edit::Item::as_str).map(str::to_owned)
605}
606
607fn child_table<'a>(
610 parent: &'a mut dyn toml_edit::TableLike,
611 key: &str,
612 inline: bool,
613) -> &'a mut dyn toml_edit::TableLike {
614 let item = parent.entry(key).or_insert_with(|| {
615 if inline {
616 toml_edit::Item::Value(toml_edit::Value::InlineTable(toml_edit::InlineTable::new()))
617 } else {
618 let mut table = toml_edit::Table::new();
619 table.set_implicit(true);
620 toml_edit::Item::Table(table)
621 }
622 });
623 item.as_table_like_mut().expect("validated as a table")
624}
625
626pub(crate) fn check_case_twins(root: &AppRoot, profile: &ProfileName) -> Result<()> {
628 let Ok(entries) = fs::read_dir(root.profiles_dir()) else {
629 return Ok(());
630 };
631 for entry in entries.flatten() {
632 let name = entry.file_name();
633 let Some(name) = name.to_str() else { continue };
634 if name != profile.as_str() && name.eq_ignore_ascii_case(profile.as_str()) {
635 return Err(Error::ProfileCaseConflict {
636 requested: profile.to_string(),
637 existing: name.to_owned(),
638 });
639 }
640 }
641 Ok(())
642}
643
644fn acquire(lock: &File, lock_path: &Path) -> Result<()> {
645 let deadline = Instant::now() + LOCK_TIMEOUT;
646 loop {
647 match lock.try_lock() {
648 Ok(()) => return Ok(()),
649 Err(std::fs::TryLockError::WouldBlock) if Instant::now() < deadline => {
650 thread::sleep(LOCK_RETRY);
651 }
652 Err(std::fs::TryLockError::WouldBlock) => {
653 return Err(Error::ConfigWrite {
654 path: lock_path.to_path_buf(),
655 source: io::Error::new(
656 io::ErrorKind::WouldBlock,
657 "another agent-profile process holds the configuration lock; retry, or check \
658 for a stuck process",
659 ),
660 });
661 }
662 Err(std::fs::TryLockError::Error(source)) => {
663 return Err(Error::ConfigWrite { path: lock_path.to_path_buf(), source });
664 }
665 }
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use std::sync::{Arc, Barrier};
673
674 fn temp_root() -> (tempfile::TempDir, AppRoot) {
675 let dir = tempfile::tempdir().unwrap();
676 let root = AppRoot::from_path(dir.path().to_path_buf());
677 (dir, root)
678 }
679
680 fn absolute(name: &str) -> String {
681 std::env::temp_dir().join(name).to_str().unwrap().to_owned()
682 }
683
684 fn set_agent(doc: &mut toml_edit::DocumentMut, id: &str, executable: &str) {
685 let agents = doc
686 .entry("agents")
687 .or_insert_with(|| {
688 let mut agents = toml_edit::Table::new();
689 agents.set_implicit(true);
690 toml_edit::Item::Table(agents)
691 })
692 .as_table_mut()
693 .expect("agents is a table");
694 let mut agent = toml_edit::Table::new();
695 agent.insert("executable", toml_edit::value(executable));
696 agents.insert(id, toml_edit::Item::Table(agent));
697 }
698
699 #[test]
700 fn app_root_override_must_be_absolute_and_non_empty() {
701 let abs = std::env::temp_dir();
702 assert_eq!(
703 AppRoot::resolve_from(Some(abs.clone().into_os_string()), None).unwrap().path(),
704 abs
705 );
706 for bad in ["", "relative/dir"] {
707 let error = AppRoot::resolve_from(Some(bad.into()), Some(abs.clone())).unwrap_err();
708 assert!(matches!(error, Error::AppRoot { .. }), "{bad:?}: {error:?}");
709 }
710 }
711
712 #[test]
713 fn non_utf8_home_override_is_an_app_root_error() {
714 #[cfg(unix)]
715 let value = {
716 use std::os::unix::ffi::OsStringExt;
717 OsString::from_vec(vec![0x66, 0xff])
718 };
719 #[cfg(windows)]
720 let value = {
721 use std::os::windows::ffi::OsStringExt;
722 OsString::from_wide(&[0x66, 0xD800])
723 };
724 let error = AppRoot::resolve_from(Some(value), None).unwrap_err();
725 assert!(matches!(error, Error::AppRoot { .. }), "{error:?}");
726 }
727
728 #[test]
729 fn app_root_defaults_to_dot_agent_profile_in_home() {
730 let home = std::env::temp_dir();
731 let root = AppRoot::resolve_from(None, Some(home.clone())).unwrap();
732 assert_eq!(root.path(), home.join(".agent-profile"));
733 assert!(matches!(AppRoot::resolve_from(None, None), Err(Error::AppRoot { .. })));
734 for bad in ["", "relative/home"] {
735 let error = AppRoot::resolve_from(None, Some(PathBuf::from(bad))).unwrap_err();
736 assert!(matches!(error, Error::AppRoot { .. }), "{bad:?}: {error:?}");
737 }
738 }
739
740 #[test]
741 fn schema_accepts_the_sp1_forms() {
742 let exe = absolute("fake-agent");
743 let text = format!("# comment\n[agents.fake]\nexecutable = {exe:?}\n\n[agents.claude]\n");
744 let config = parse(Path::new("c"), text.as_bytes()).unwrap();
745 assert_eq!(config.agent_executable("fake"), Some(Path::new(&exe)));
746 assert_eq!(config.agent_executable("claude"), None);
747 assert_eq!(config.agent_executable("codex"), None);
748 assert_eq!(config.configured_agents().collect::<Vec<_>>(), ["claude", "fake"]);
749 assert_eq!(parse(Path::new("c"), b"").unwrap(), Config::default());
750 }
751
752 #[test]
753 fn schema_rejects_every_error_class_naming_the_key() {
754 let exe = absolute("x");
755 let cases = [
756 (b"\xff".to_vec(), None),
757 (b"[agents\n".to_vec(), None),
758 (b"default = \"work\"\n".to_vec(), Some("default")),
759 (b"agents = 3\n".to_vec(), Some("agents")),
760 (b"[agents]\nFake = {}\n".to_vec(), Some("agents.Fake")),
761 (b"[agents]\nfake = 3\n".to_vec(), Some("agents.fake")),
762 (format!("[agents.fake]\npath = {exe:?}\n").into_bytes(), Some("agents.fake.path")),
763 (b"[agents.fake]\nexecutable = 3\n".to_vec(), Some("agents.fake.executable")),
764 (
765 b"[agents.fake]\nexecutable = \"relative/x\"\n".to_vec(),
766 Some("agents.fake.executable"),
767 ),
768 ];
769 for (bytes, key) in cases {
770 match parse(Path::new("c"), &bytes) {
771 Err(Error::ConfigInvalid { key: actual, .. }) => {
772 assert_eq!(actual.as_deref(), key, "{:?}", String::from_utf8_lossy(&bytes))
773 }
774 other => panic!("{:?}: {other:?}", String::from_utf8_lossy(&bytes)),
775 }
776 }
777 }
778
779 #[test]
780 fn failed_replacement_preserves_previous_config_and_leaves_no_temp_file() {
781 let (_dir, root) = temp_root();
782 let exe = absolute("old");
783 let previous = format!("[agents.fake]\nexecutable = {exe:?}\n");
784 fs::write(root.config_path(), &previous).unwrap();
785 let error = update_with(
786 &root,
787 |doc| {
788 set_agent(doc, "fake", &absolute("new"));
789 Ok(())
790 },
791 || Ok(()),
792 |temp, _destination| {
793 drop(temp);
794 Err(io::Error::other("injected replace failure"))
795 },
796 )
797 .unwrap_err();
798 assert!(matches!(error, Error::ConfigWrite { .. }), "{error:?}");
799 assert_eq!(fs::read_to_string(root.config_path()).unwrap(), previous);
800 assert_eq!(temp_files(&root), Vec::<String>::new());
801 }
802
803 #[test]
804 fn reader_during_write_sees_previous_then_new_complete_content() {
805 let (_dir, root) = temp_root();
806 let previous = format!("[agents.fake]\nexecutable = {:?}\n", absolute("old"));
807 fs::write(root.config_path(), &previous).unwrap();
808 let barrier = Arc::new(Barrier::new(2));
809 let reader = {
810 let barrier = Arc::clone(&barrier);
811 let root = root.clone();
812 thread::spawn(move || {
813 barrier.wait();
814 let seen = fs::read_to_string(root.config_path()).unwrap();
815 barrier.wait();
816 seen
817 })
818 };
819 update_with(
820 &root,
821 |doc| {
822 set_agent(doc, "fake", &absolute("new"));
823 Ok(())
824 },
825 || {
826 barrier.wait();
827 barrier.wait();
828 Ok(())
829 },
830 |temp, destination| temp.persist(destination).map(drop).map_err(|error| error.error),
831 )
832 .unwrap();
833 assert_eq!(reader.join().unwrap(), previous);
834 let config = Config::load(&root).unwrap();
835 assert_eq!(config.agent_executable("fake"), Some(Path::new(&absolute("new"))));
836 }
837
838 #[test]
839 fn update_sweeps_stale_temp_files_and_never_reads_them() {
840 let (_dir, root) = temp_root();
841 fs::write(root.path().join(".config.toml.stale.tmp"), "garbage = [").unwrap();
842 update(&root, |doc| {
843 set_agent(doc, "fake", &absolute("x"));
844 Ok(())
845 })
846 .unwrap();
847 assert_eq!(temp_files(&root), Vec::<String>::new());
848 assert!(Config::load(&root).unwrap().agent_executable("fake").is_some());
849 }
850
851 fn host<'a>(unix: &'a str, windows: &'a str) -> &'a str {
853 if cfg!(windows) { windows } else { unix }
854 }
855
856 fn name(text: &str) -> ProfileName {
857 ProfileName::parse(text, Platform::host()).unwrap()
858 }
859
860 fn agent(id: &str) -> AgentId {
861 AgentId::parse(id).unwrap()
862 }
863
864 fn key(root: &str) -> String {
865 toml_edit::Key::new(root).display_repr().into_owned()
866 }
867
868 #[test]
869 fn schema_accepts_the_sp3_forms() {
870 let acme = host("/src/acme", r"C:\src\acme");
871 let text = format!(
872 "default_profile = \"work\"\n\n[repositories.{}]\nprofile = \"work\"\nagents = {{ claude = \"personal\" }}\n\n[repositories.{}]\n\n[repositories.{}]\nprofile = \"other\"\n",
873 key(acme),
874 key(host("/empty", r"C:\empty")),
875 key(host(r"C:\elsewhere", "/elsewhere")),
876 );
877 let config = parse(Path::new("c"), text.as_bytes()).unwrap();
878 assert_eq!(config.default_profile(), Some(&name("work")));
879 let expected = Mapping {
880 profile: Some(name("work")),
881 agents: BTreeMap::from([(agent("claude"), name("personal"))]),
882 };
883 assert_eq!(config.mapping(Path::new(acme)), Some(&expected));
884 let trailing = format!("{acme}{}", std::path::MAIN_SEPARATOR);
885 assert_eq!(config.mapping(Path::new(&trailing)), Some(&expected));
886 assert_eq!(
887 config.mapping(Path::new(host("/empty", r"C:\empty"))),
888 Some(&Mapping::default())
889 );
890 assert_eq!(config.mappings().count(), 3);
891 assert_eq!(config.mapping(Path::new(host("/src", r"C:\src"))), None);
892 #[cfg(windows)]
893 assert_eq!(config.mapping(Path::new("C:/src/acme")), Some(&expected));
894 }
895
896 #[test]
897 fn schema_rejects_every_sp3_error_class_naming_the_key() {
898 let acme = key(host("/acme", r"C:\acme"));
899 let entry = |body: &str| format!("[repositories.{acme}]\n{body}\n");
900 let entry_key = format!("repositories.{acme}");
901 let cases = [
902 ("default_profile = 3\n".to_owned(), "default_profile".to_owned(), "must be a string"),
903 (
904 "default_profile = \"link\"\n".to_owned(),
905 "default_profile".to_owned(),
906 "invalid profile name \"link\": \"link\" is a reserved command word",
907 ),
908 ("repositories = 3\n".to_owned(), "repositories".to_owned(), "must be a table"),
909 (
910 "[repositories.relative]\n".to_owned(),
911 "repositories.relative".to_owned(),
912 "must be an absolute path",
913 ),
914 (format!("[repositories]\n{acme} = 3\n"), entry_key.clone(), "must be a table"),
915 (entry("profile = 3"), format!("{entry_key}.profile"), "must be a string"),
916 (entry("profile = \".x\""), format!("{entry_key}.profile"), "invalid profile name"),
917 (entry("agents = 3"), format!("{entry_key}.agents"), "must be a table"),
918 (
919 entry("agents = { Claude = \"x\" }"),
920 format!("{entry_key}.agents.Claude"),
921 "agent ids must match",
922 ),
923 (
924 entry("agents = { claude = 3 }"),
925 format!("{entry_key}.agents.claude"),
926 "must be a string",
927 ),
928 (
929 entry("agents = { claude = \"a b\" }"),
930 format!("{entry_key}.agents.claude"),
931 "invalid profile name",
932 ),
933 (entry("path = \"x\""), format!("{entry_key}.path"), "unknown key"),
934 ];
935 for (text, expected_key, detail) in cases {
936 match parse(Path::new("c"), text.as_bytes()) {
937 Err(Error::ConfigInvalid { key: Some(actual), detail: actual_detail, .. }) => {
938 assert_eq!(actual, expected_key, "{text:?}");
939 assert!(actual_detail.starts_with(detail), "{text:?}: {actual_detail}");
940 }
941 other => panic!("{text:?}: {other:?}"),
942 }
943 }
944 }
945
946 #[test]
947 fn schema_accepts_keys_absolute_in_either_platform_form() {
948 for root in ["/a", r"C:\a", "C:/a", r"\\server\share\a", "z:/"] {
949 let text = format!("[repositories.{}]\n", key(root));
950 assert!(parse(Path::new("c"), text.as_bytes()).is_ok(), "{root}");
951 }
952 for root in ["a", "C:", "C:a", r"\a", "~/a", ""] {
953 let text = format!("[repositories.{}]\n", key(root));
954 assert!(
955 matches!(parse(Path::new("c"), text.as_bytes()), Err(Error::ConfigInvalid { .. })),
956 "{root:?}"
957 );
958 }
959 }
960
961 #[test]
962 fn component_equal_keys_are_rejected_naming_both() {
963 let keys: &[&str] =
964 if cfg!(windows) { &[r"C:\x", "C:/x", r"c:\x\"] } else { &["/a", "/a/"] };
965 let text: String =
966 keys.iter().map(|root| format!("[repositories.{}]\n", key(root))).collect();
967 match parse(Path::new("c"), text.as_bytes()) {
968 Err(Error::ConfigInvalid { key: Some(first), detail, .. }) => {
969 let mut sorted = keys.to_vec();
970 sorted.sort();
971 assert_eq!(first, format!("repositories.{}", key(sorted[0])));
972 assert_eq!(
973 detail,
974 format!("names the same directory as repositories.{}", key(sorted[1]))
975 );
976 }
977 other => panic!("{other:?}"),
978 }
979 }
980
981 #[test]
982 fn link_reports_every_outcome_and_writes_only_on_change() {
983 let (_dir, root) = temp_root();
984 let repo = PathBuf::from(host("/src/acme", r"C:\src\acme"));
985 let claude = agent("claude");
986 assert_eq!(link(&root, &repo, None, &name("work")).unwrap(), LinkOutcome::Linked);
987 assert_eq!(
988 link(&root, &repo, Some(&claude), &name("personal")).unwrap(),
989 LinkOutcome::Linked
990 );
991 let written = fs::read(root.config_path()).unwrap();
992 assert_eq!(link(&root, &repo, None, &name("work")).unwrap(), LinkOutcome::AlreadyLinked);
993 assert_eq!(
994 link(&root, &repo, Some(&claude), &name("personal")).unwrap(),
995 LinkOutcome::AlreadyLinked
996 );
997 assert_eq!(fs::read(root.config_path()).unwrap(), written, "no write when already linked");
998 assert_eq!(
999 link(&root, &repo, None, &name("other")).unwrap(),
1000 LinkOutcome::Changed { old: name("work") }
1001 );
1002 assert_eq!(
1003 link(&root, &repo, Some(&claude), &name("work")).unwrap(),
1004 LinkOutcome::Changed { old: name("personal") }
1005 );
1006 let config = Config::load(&root).unwrap();
1007 assert_eq!(
1008 config.mapping(&repo),
1009 Some(&Mapping {
1010 profile: Some(name("other")),
1011 agents: BTreeMap::from([(claude, name("work"))]),
1012 })
1013 );
1014 }
1015
1016 #[test]
1017 fn link_and_unlink_preserve_comments_and_formatting() {
1018 let (_dir, root) = temp_root();
1019 let repo = host("/src/acme", r"C:\src\acme");
1020 let other = host("/src/other", r"C:\src\other");
1021 let text = format!(
1022 "# my settings\ndefault_profile = \"work\" # trailing\n\n[repositories.{}]\nprofile = \"work\" # keep\n",
1023 key(other)
1024 );
1025 fs::write(root.config_path(), &text).unwrap();
1026 link(&root, Path::new(repo), Some(&agent("claude")), &name("personal")).unwrap();
1027 let linked = fs::read_to_string(root.config_path()).unwrap();
1028 assert!(linked.starts_with(&text), "{linked}");
1029 assert_eq!(
1030 unlink(&root, &[PathBuf::from(repo)], Some(&agent("claude"))).unwrap(),
1031 UnlinkOutcome::Unlinked { root: PathBuf::from(repo), old: name("personal") }
1032 );
1033 assert_eq!(fs::read_to_string(root.config_path()).unwrap(), text);
1034 }
1035
1036 #[test]
1037 fn unlink_removes_empty_agents_and_empty_entries() {
1038 let (_dir, root) = temp_root();
1039 let repo = PathBuf::from(host("/src/acme", r"C:\src\acme"));
1040 let keys = [repo.clone()];
1041 link(&root, &repo, None, &name("work")).unwrap();
1042 link(&root, &repo, Some(&agent("claude")), &name("personal")).unwrap();
1043 assert_eq!(
1044 unlink(&root, &keys, None).unwrap(),
1045 UnlinkOutcome::Unlinked { root: repo.clone(), old: name("work") }
1046 );
1047 assert_eq!(
1048 unlink(&root, &keys, None).unwrap(),
1049 UnlinkOutcome::NothingToRemove { shown: repo.clone() }
1050 );
1051 assert_eq!(
1052 unlink(&root, &keys, Some(&agent("codex"))).unwrap(),
1053 UnlinkOutcome::NothingToRemove { shown: repo.clone() }
1054 );
1055 assert!(Config::load(&root).unwrap().mapping(&repo).is_some());
1056 assert_eq!(
1057 unlink(&root, &keys, Some(&agent("claude"))).unwrap(),
1058 UnlinkOutcome::Unlinked { root: repo.clone(), old: name("personal") }
1059 );
1060 assert_eq!(Config::load(&root).unwrap().mapping(&repo), None);
1061 let text = fs::read_to_string(root.config_path()).unwrap();
1062 assert!(!text.contains("acme"), "{text}");
1063 }
1064
1065 #[test]
1066 fn link_and_unlink_find_a_component_equal_stored_key() {
1067 let (_dir, root) = temp_root();
1068 let stored = host("/src/acme/", "C:/src/acme/");
1069 fs::write(
1070 root.config_path(),
1071 format!("[repositories.{}]\nprofile = \"work\"\n", key(stored)),
1072 )
1073 .unwrap();
1074 let repo = PathBuf::from(host("/src/acme", r"C:\src\acme"));
1075 assert_eq!(
1076 link(&root, &repo, Some(&agent("claude")), &name("personal")).unwrap(),
1077 LinkOutcome::Linked
1078 );
1079 assert_eq!(Config::load(&root).unwrap().mappings().count(), 1);
1080 assert_eq!(
1081 unlink(&root, std::slice::from_ref(&repo), None).unwrap(),
1082 UnlinkOutcome::Unlinked { root: PathBuf::from(stored), old: name("work") }
1083 );
1084 }
1085
1086 #[test]
1087 fn unlink_takes_the_first_key_with_a_mapping_at_that_field() {
1088 let (_dir, root) = temp_root();
1089 let first = PathBuf::from(host("/a", r"C:\a"));
1090 let second = PathBuf::from(host("/b", r"C:\b"));
1091 let third = PathBuf::from(host("/c", r"C:\c"));
1092 link(&root, &first, Some(&agent("claude")), &name("x")).unwrap();
1093 link(&root, &second, None, &name("y")).unwrap();
1094 link(&root, &third, None, &name("z")).unwrap();
1095 let keys = [first.clone(), second.clone(), third.clone()];
1096 assert_eq!(
1097 unlink(&root, &keys, None).unwrap(),
1098 UnlinkOutcome::Unlinked { root: second, old: name("y") }
1099 );
1100 let missing = [PathBuf::from(host("/gone", r"C:\gone")), first.clone()];
1101 assert_eq!(
1102 unlink(&root, &missing, None).unwrap(),
1103 UnlinkOutcome::NothingToRemove { shown: missing[0].clone() }
1104 );
1105 assert_eq!(
1106 unlink(&root, &missing, Some(&agent("claude"))).unwrap(),
1107 UnlinkOutcome::Unlinked { root: first, old: name("x") }
1108 );
1109 }
1110
1111 #[test]
1114 fn unlink_removes_the_first_key_that_matches_not_the_first_stored_entry() {
1115 let (_dir, root) = temp_root();
1116 let later = PathBuf::from(host("/base/acme", r"C:\base\acme"));
1117 let earlier = PathBuf::from(host("/base/wt/../acme", r"C:\base\wt\..\acme"));
1118 fs::write(
1120 root.config_path(),
1121 format!(
1122 "[repositories.{}]\nprofile = \"stale\"\n\n[repositories.{}]\nprofile = \"live\"\n",
1123 key(earlier.to_str().unwrap()),
1124 key(later.to_str().unwrap()),
1125 ),
1126 )
1127 .unwrap();
1128 let keys = [later.clone(), earlier.clone()];
1129 assert_eq!(
1130 unlink(&root, &keys, None).unwrap(),
1131 UnlinkOutcome::Unlinked { root: later, old: name("live") }
1132 );
1133 let config = Config::load(&root).unwrap();
1134 assert_eq!(
1135 config.mapping(&earlier).and_then(|mapping| mapping.profile.as_ref()),
1136 Some(&name("stale")),
1137 "the entry the second key names must survive"
1138 );
1139 }
1140
1141 #[test]
1143 fn a_key_differing_only_in_letter_case_never_matches_a_root() {
1144 let stored = host("/src/Acme", r"C:\src\Acme");
1145 let other_case = host("/src/acme", r"C:\src\acme");
1146 let text = format!("[repositories.{}]\nprofile = \"work\"\n", key(stored));
1147 let config = parse(Path::new("c"), text.as_bytes()).unwrap();
1148 assert!(config.mapping(Path::new(stored)).is_some());
1149 assert_eq!(config.mapping(Path::new(other_case)), None);
1150 }
1151
1152 #[test]
1153 fn unlink_of_nothing_creates_no_configuration_file() {
1154 let (_dir, root) = temp_root();
1155 let keys = [PathBuf::from(host("/a", r"C:\a"))];
1156 assert!(matches!(unlink(&root, &keys, None), Ok(UnlinkOutcome::NothingToRemove { .. })));
1157 assert!(!root.config_path().exists());
1158 }
1159
1160 #[test]
1163 fn a_no_op_unlink_creates_the_application_root_and_its_lock_file_only() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let root = AppRoot::from_path(dir.path().join("missing-root"));
1166 let keys = [PathBuf::from(host("/a", r"C:\a"))];
1167 assert!(matches!(unlink(&root, &keys, None), Ok(UnlinkOutcome::NothingToRemove { .. })));
1168 let mut entries: Vec<String> = fs::read_dir(root.path())
1169 .unwrap()
1170 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1171 .collect();
1172 entries.sort();
1173 assert_eq!(entries, ["config.toml.lock"]);
1174 }
1175
1176 #[test]
1177 fn link_refuses_a_case_twin_and_a_non_utf8_root() {
1178 let (_dir, root) = temp_root();
1179 fs::create_dir_all(root.profiles_dir().join("work")).unwrap();
1180 let repo = PathBuf::from(host("/a", r"C:\a"));
1181 let error = link(&root, &repo, None, &name("WORK")).unwrap_err();
1182 assert!(
1183 matches!(error, Error::ProfileCaseConflict { ref existing, .. } if existing == "work"),
1184 "{error:?}"
1185 );
1186 assert!(!root.config_path().exists());
1187 #[cfg(unix)]
1188 let non_utf8 = {
1189 use std::os::unix::ffi::OsStringExt;
1190 PathBuf::from(OsString::from_vec(b"/a\xff".to_vec()))
1191 };
1192 #[cfg(windows)]
1193 let non_utf8 = {
1194 use std::os::windows::ffi::OsStringExt;
1195 PathBuf::from(OsString::from_wide(&[0x43, 0x3A, 0x5C, 0xD800]))
1196 };
1197 match link(&root, &non_utf8, None, &name("work")).unwrap_err() {
1198 Error::Repository { path, reason } => {
1199 assert_eq!(path, non_utf8);
1200 assert_eq!(reason, "a repository path that is not valid UTF-8 cannot be linked");
1201 }
1202 other => panic!("{other:?}"),
1203 }
1204 }
1205
1206 #[test]
1207 fn mappings_referencing_finds_case_twins_non_canonical_keys_and_agent_fields() {
1208 let text = format!(
1209 "[repositories.{}]\nprofile = \"Work\"\n\n[repositories.{}]\nagents = {{ claude = \"work\", codex = \"other\" }}\n\n[repositories.{}]\nprofile = \"other\"\n",
1210 key(host("/a", r"C:\a")),
1211 key(host("/b/../c/", "c:/b/../c/")),
1212 key(host("/d", r"C:\d")),
1213 );
1214 let config = parse(Path::new("c"), text.as_bytes()).unwrap();
1215 assert_eq!(
1216 config.mappings_referencing(&name("work")),
1217 [
1218 Reference {
1219 root: PathBuf::from(host("/a", r"C:\a")),
1220 agent: None,
1221 profile: name("Work"),
1222 },
1223 Reference {
1224 root: PathBuf::from(host("/b/../c/", "c:/b/../c/")),
1225 agent: Some(agent("claude")),
1226 profile: name("work"),
1227 },
1228 ]
1229 );
1230 assert_eq!(config.mappings_referencing(&name("none")), []);
1231 }
1232
1233 #[test]
1234 fn an_edit_that_changes_nothing_writes_nothing() {
1235 let (_dir, root) = temp_root();
1236 let text = "default_profile='work' # odd spacing\n";
1237 fs::write(root.config_path(), text).unwrap();
1238 update_with(
1239 &root,
1240 |_| Ok(()),
1241 || panic!("nothing to persist"),
1242 |_, _| panic!("no replace"),
1243 )
1244 .unwrap();
1245 assert_eq!(fs::read_to_string(root.config_path()).unwrap(), text);
1246 }
1247
1248 #[test]
1249 fn already_linked_and_nothing_to_remove_keep_crlf_and_bom_files_byte_identical() {
1250 let repo = host("/src/acme", r"C:\src\acme");
1251 let body = format!(
1252 "default_profile = \"work\"\r\n\r\n[repositories.{}]\r\nprofile = \"work\"\r\n",
1253 key(repo)
1254 );
1255 for bytes in
1256 [body.clone().into_bytes(), [b"\xef\xbb\xbf".as_slice(), body.as_bytes()].concat()]
1257 {
1258 let (_dir, root) = temp_root();
1259 fs::write(root.config_path(), &bytes).unwrap();
1260 assert_eq!(
1261 link(&root, Path::new(repo), None, &name("work")).unwrap(),
1262 LinkOutcome::AlreadyLinked
1263 );
1264 assert_eq!(
1265 fs::read(root.config_path()).unwrap(),
1266 bytes,
1267 "AlreadyLinked rewrote the file"
1268 );
1269 let missing = [PathBuf::from(host("/gone", r"C:\gone"))];
1270 assert!(matches!(
1271 unlink(&root, &missing, None).unwrap(),
1272 UnlinkOutcome::NothingToRemove { .. }
1273 ));
1274 assert_eq!(
1275 fs::read(root.config_path()).unwrap(),
1276 bytes,
1277 "NothingToRemove rewrote the file"
1278 );
1279 }
1280 }
1281
1282 fn temp_files(root: &AppRoot) -> Vec<String> {
1283 fs::read_dir(root.path())
1284 .unwrap()
1285 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1286 .filter(|name| name.starts_with(TEMP_PREFIX) && name.ends_with(TEMP_SUFFIX))
1287 .collect()
1288 }
1289}