Skip to main content

agent_profile/
config.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! The application root (spec §7), `config.toml` strict reads (spec §17) and locked, atomic writes
8//! (spec §18, §18.1).
9
10use 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
23/// Environment variable that overrides the application root.
24pub 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/// The directory holding `config.toml` and `profiles/`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AppRoot(PathBuf);
36
37impl AppRoot {
38    /// A root at an explicit path. Tests use this instead of changing the process environment.
39    pub fn from_path(path: PathBuf) -> AppRoot {
40        AppRoot(path)
41    }
42
43    /// `AGENT_PROFILE_HOME` when set, otherwise `<home>/.agent-profile`. Never creates anything.
44    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/// The validated contents of `config.toml`.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct Config {
93    agents: BTreeMap<String, Option<PathBuf>>,
94    default_profile: Option<ProfileName>,
95    /// Keyed by the stored key string, so iteration is ordered by it.
96    repositories: BTreeMap<String, Mapping>,
97}
98
99/// One `[repositories.'<root>']` entry (SP3 design §6.1).
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Mapping {
102    pub profile: Option<ProfileName>,
103    pub agents: BTreeMap<AgentId, ProfileName>,
104}
105
106/// A mapping field that names a profile (SP3 design §4.2).
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Reference {
109    /// The stored key.
110    pub root: PathBuf,
111    /// `None` for the entry's `profile`, the agent for an `agents.<id>` field.
112    pub agent: Option<AgentId>,
113    /// The stored spelling.
114    pub profile: ProfileName,
115}
116
117/// What `link` did.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum LinkOutcome {
120    Linked,
121    Changed { old: ProfileName },
122    AlreadyLinked,
123}
124
125/// What `unlink` did.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum UnlinkOutcome {
128    /// `root` is the stored key of the entry the mapping was removed from.
129    Unlinked { root: PathBuf, old: ProfileName },
130    /// `shown` is the first candidate key.
131    NothingToRemove { shown: PathBuf },
132}
133
134impl Config {
135    /// Reads `<root>/config.toml`. A missing file is an empty configuration; anything invalid is an error.
136    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    /// The explicit executable override for an agent, if configured.
146    pub fn agent_executable(&self, id: &str) -> Option<&Path> {
147        self.agents.get(id).and_then(|executable| executable.as_deref())
148    }
149
150    /// Every agent id that has an `agents.<id>` table, in sorted order.
151    pub fn configured_agents(&self) -> impl Iterator<Item = &str> {
152        self.agents.keys().map(String::as_str)
153    }
154
155    /// The hand-edited global default (SP3 design D3).
156    pub fn default_profile(&self) -> Option<&ProfileName> {
157        self.default_profile.as_ref()
158    }
159
160    /// The entry whose key is component-equal to `root`; validation guarantees there is at most one.
161    pub fn mapping(&self, root: &Path) -> Option<&Mapping> {
162        self.mappings().find(|(key, _)| *key == root).map(|(_, mapping)| mapping)
163    }
164
165    /// Every entry, ordered by the stored key string.
166    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    /// Every mapping field naming `profile`, ignoring ASCII case, so a case-only twin is never missed.
171    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
212/// Applies the strict schema (SP1 design §6.2, SP3 design §6.1) to a parsed document.
213fn 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
237/// `repositories.<key>` with the key quoted as TOML would write it.
238fn repository_key(root: &str) -> String {
239    format!("repositories.{}", toml_edit::Key::new(root).display_repr())
240}
241
242/// Absolute in Unix form (`/…`) or Windows form (`C:\…`, `C:/…`, `\\…`), whatever the host (SP3 design §6.1).
243fn 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
252/// Whether a stored key is in this host's own absolute form: `/…` on Unix, anything but `/…` on Windows (SP5b
253/// design §4). The other platform's keys are `foreign`.
254pub 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
360/// Locked, atomic read-modify-write of `config.toml` (spec §18, design §6.4).
361pub 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    // 1. The root.
384    fs::create_dir_all(root.path()).map_err(|source| write_error(root.path(), source))?;
385
386    // 2. The exclusive lock, bounded.
387    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    // 3. Best-effort sweep of temp files left by crashed writers.
398    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    // 4. The latest configuration, validated.
409    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    // 5. The edit, re-validated. An edit that changes nothing writes nothing. The rendering before the edit is
424    // the comparison, not the file text: rendering normalizes line endings and drops a byte-order mark.
425    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    // 6. The temp file in the same directory, synced.
434    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    // 7. The atomic replace. On failure `replace` drops the temp file, which deletes it.
444    replace(temp, &config_path).map_err(|source| write_error(&config_path, source))?;
445
446    // 7a. Durability of the rename on Unix.
447    #[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    // 8. The lock is released when `lock` drops.
462    drop(lock);
463    Ok(())
464}
465
466/// Reads the configuration under the same exclusive lock as `update` (spec §18, design §6.4). It does
467/// not create the root directory and does not sweep temp files: those are write-path side effects.
468pub 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
486/// Maps `repository` (or only `agent` in it) to `profile` (SP3 design §6.2). The caller has discovered
487/// `repository`.
488pub 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(&current, Platform::host()).expect("validated profile name"),
529        },
530    }
531}
532
533/// Removes the mapping at the first of `keys` that has one at that field, under one lock (SP3 design §6.2).
534/// `keys` is never empty.
535pub 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
586/// The stored `repositories` keys of a validated document.
587fn 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
595/// The profile an entry maps at the field `agent` selects.
596fn 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
607/// The table at `key` in `parent`, created when missing: an inline table when `inline`, otherwise an
608/// implicit standard table.
609fn 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
626/// Refuses a profile whose name differs from an existing `profiles/` entry only in ASCII case (SP1 design §7.3).
627pub(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    /// The Unix spelling on Unix, the Windows spelling on Windows.
852    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    /// Design §6.2 step 1 searches the candidate keys in order, not the stored entries in file order: the first
1112    /// *key* that matches any entry wins, even when another entry sits earlier in the file.
1113    #[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        // `earlier` is written first and is a different key by components, so an entry-major search finds it first.
1119        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    /// Design §4.2: keys are compared by `Path` components, and letter case is part of a component.
1142    #[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    /// "No write" (design §6.2) means no configuration is written; the locked update still creates the application
1161    /// root and its lock file, and nothing else.
1162    #[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}