Skip to main content

agent_profile/
lifecycle.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 profile lifecycle: materializing and deleting what an adapter owns for a profile (SP5a design §3).
8//!
9//! Like `config::link` and `config::unlink`, the logic is a pure function over the application root that
10//! returns an outcome value; the CLI layer renders it and decides the exit code.
11
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16use crate::adapter::{self, Adapter, ProfilePath, ProfilePresence};
17use crate::config::{self, AppRoot, Config, check_case_twins};
18use crate::error::{Error, Result};
19use crate::name::{AgentId, Platform, ProfileName};
20
21/// What `create` did (SP5a design §3).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CreateOutcome {
24    /// Every path the adapter owns was ensured; a missing one was created.
25    Created,
26    /// Every path already existed, so nothing was written.
27    AlreadyExists,
28}
29
30/// Ensures the paths `adapter` owns for `profile` under `root` exactly once: an already materialized
31/// profile is left untouched, so a user's hand-edited configuration file is never overwritten. The
32/// returned path is the first of the adapter's paths, the profile directory; the function is the same
33/// kind of pure, outcome-returning function as `config::link`.
34///
35/// The outcome is decided from `presence` *before* anything is written, so it reads the state the user
36/// sees, not the state this call would leave behind.
37pub fn create(
38    root: &AppRoot,
39    adapter: &dyn Adapter,
40    profile: &ProfileName,
41) -> Result<(CreateOutcome, PathBuf)> {
42    check_case_twins(root, profile)?;
43    let paths: Vec<ProfilePath> = adapter
44        .paths(root, profile)
45        .into_iter()
46        .map(|(path, kind)| ProfilePath { path, kind, existed: false })
47        .collect();
48    let outcome = if adapter.presence(root, profile) == ProfilePresence::Materialized {
49        CreateOutcome::AlreadyExists
50    } else {
51        adapter::ensure_paths(&paths)?;
52        CreateOutcome::Created
53    };
54    Ok((outcome, paths[0].path.clone()))
55}
56
57/// Text for one blocking reference: the stored key for a repository-wide `profile` field, the stored key
58/// with the agent table for an `agents.<id>` field, and the literal `default_profile` for the global
59/// default (SP5a design §3).
60fn blocking_label(agent: Option<&AgentId>, root: &Path) -> String {
61    match agent {
62        None => root.display().to_string(),
63        Some(agent) => format!("{} (agents.{agent})", root.display()),
64    }
65}
66
67/// Every mapping field and the global default that names `profile` for `agent`, ignoring ASCII case, in
68/// the order the mapping fields are read and with `default_profile` last. An empty result means nothing
69/// stands in the way of deleting the profile's directory for `agent`.
70pub fn blocking_references(config: &Config, agent: &AgentId, profile: &ProfileName) -> Vec<String> {
71    let mut references: Vec<String> = config
72        .mappings_referencing(profile)
73        .into_iter()
74        // A repository-wide mapping blocks every agent; an agent field blocks only that agent, so
75        // another agent's mapping never blocks this one.
76        .filter(|reference| reference.agent.is_none() || reference.agent.as_ref() == Some(agent))
77        .map(|reference| blocking_label(reference.agent.as_ref(), &reference.root))
78        .collect();
79    if config
80        .default_profile()
81        .is_some_and(|name| name.as_str().eq_ignore_ascii_case(profile.as_str()))
82    {
83        references.push("default_profile".to_owned());
84    }
85    references
86}
87
88/// The profile directory `delete` would remove: `<root>/profiles/<P>/<agent>`, shown to the user
89/// un-canonicalized (SP5a design §3). `None` when nothing is there. `Error::ProfileReferenced` is not
90/// consulted here; `blocking_references` is the caller's advisory or locked check.
91///
92/// Safety is structural (SP5a design §3): the path is always inside `<root>/profiles` by construction, and
93/// every component below the root is checked so a symlink or a containment escape is refused before any
94/// removal, whatever the caller plans to `remove_dir_all`.
95pub fn delete_target(
96    root: &AppRoot,
97    agent: &AgentId,
98    profile: &ProfileName,
99) -> Result<Option<PathBuf>> {
100    check_case_twins(root, profile)?;
101    let parent = root.profiles_dir().join(profile.as_str());
102    let target = parent.join(agent.as_str());
103    // Existence decides first (SP5a design §3.2): a missing entry branches straight to the idempotent
104    // no-directory outcome (`Ok(None)`) and the containment refusals below never run, so the no-entry
105    // result stays exit 0 by design even when a parent above the target is a symlink.
106    let Ok(metadata) = fs::symlink_metadata(&target) else {
107        return Ok(None); // not there, so nothing to delete
108    };
109    // `symlink_metadata` does not follow the final path component, so the `is_symlink` test below sees
110    // the entry itself and not its target: the rustdoc of `FileType::is_symlink` ties it to
111    // `symlink_metadata` for exactly this reason, because `fs::metadata` follows links and would always
112    // answer false. A Windows junction carries the reparse-point attribute, so `is_symlink` is true for
113    // a junction too and this one test refuses both spellings.
114    if metadata.is_symlink() {
115        return Err(symlink_refusal(target));
116    }
117    // The parent one level above the target is refused after the target's own entry: `symlink_metadata`
118    // lstats the final component only, so a symlinked parent never changes the final component's
119    // identity and the no-entry branch above stays reachable.
120    if symlinked(&parent)? {
121        return Err(symlink_refusal(parent));
122    }
123    if !metadata.is_dir() {
124        return Err(Error::ProfileDir {
125            path: target,
126            source: io::Error::other("is not a directory"),
127        });
128    }
129    let resolved_profiles = fs::canonicalize(root.profiles_dir())
130        .map_err(|source| Error::ProfileDir { path: root.profiles_dir(), source })?;
131    let resolved = fs::canonicalize(&target)
132        .map_err(|source| Error::ProfileDir { path: target.clone(), source })?;
133    if !resolved.starts_with(&resolved_profiles) {
134        return Err(Error::ProfileDir {
135            path: target,
136            source: io::Error::other("resolves outside the profiles directory"),
137        });
138    }
139    Ok(Some(target))
140}
141
142/// Whether the entry at `path` is itself a symlink (or, on Windows, a junction). `Ok(false)` when the
143/// entry is missing, so the caller's symlink check never fires on `NotFound`.
144fn symlinked(path: &Path) -> Result<bool> {
145    match fs::symlink_metadata(path) {
146        Ok(metadata) => Ok(metadata.is_symlink()),
147        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
148        Err(source) => Err(Error::ProfileDir { path: path.to_path_buf(), source }),
149    }
150}
151
152/// The refusal for a symlinked entry, with the fixed message the tests anchor on.
153fn symlink_refusal(path: PathBuf) -> Error {
154    Error::ProfileDir {
155        path,
156        source: io::Error::other("is a symbolic link or junction; refusing to delete through it"),
157    }
158}
159
160/// What `delete` did (SP5a design §3).
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum DeleteOutcome {
163    /// The profile directory was removed; `path` is the un-canonicalized directory, `parent_note` a
164    /// non-fatal problem with removing the now-empty profile directory above it.
165    Deleted { path: PathBuf, parent_note: Option<String> },
166    /// Nothing was there by the time the locked delete ran: the profile vanished between the advisory
167    /// check and the lock, which is the state the user asked for, not an error.
168    Vanished { path: PathBuf },
169}
170
171/// Removes the profile directory for `agent` under the same exclusive lock `link` and `update` take
172/// (spec §18), re-checking `delete_target` and `blocking_references` inside it: the advisory checks outside
173/// the lock can be seconds stale, and a deletion that never leaves a mapping or the global default
174/// pointing at nothing is the invariant this function exists to hold (SP5a design §3).
175pub fn delete(root: &AppRoot, agent: &AgentId, profile: &ProfileName) -> Result<DeleteOutcome> {
176    config::locked(root, |config| {
177        let target = root.profiles_dir().join(profile.as_str()).join(agent.as_str());
178        let Some(target) = delete_target(root, agent, profile)? else {
179            return Ok(DeleteOutcome::Vanished { path: target });
180        };
181        let references = blocking_references(config, agent, profile);
182        if !references.is_empty() {
183            return Err(Error::ProfileReferenced {
184                agent: agent.to_string(),
185                profile: profile.to_string(),
186                references,
187            });
188        }
189        fs::remove_dir_all(&target).map_err(|error| {
190            Error::ProfileDir {
191                path: target.clone(),
192                // A failed removal may leave any suffix of the tree removed, so the raw error is
193                // re-worded to send the user back here instead of leaving the root half-deleted and quiet.
194                source: io::Error::other(format!(
195                    "{error}; the profile may be partially removed, retry the deletion"
196                )),
197            }
198        })?;
199        // The profile directories above the target turn empty as agents' profiles are deleted one by one;
200        // an empty one is swept. A failure to sweep is a note, not an error: the profile itself is gone.
201        let parent = target.parent().expect("the target is a profile directory below its parent");
202        let mut parent_note = None;
203        if fs::read_dir(parent).is_ok_and(|mut entries| entries.next().is_none())
204            && let Err(error) = fs::remove_dir(parent)
205        {
206            parent_note =
207                Some(format!("could not remove empty directory {}: {error}", parent.display()));
208        }
209        Ok(DeleteOutcome::Deleted { path: target, parent_note })
210    })
211}
212
213/// What `list` and `profiles` show (SP5a design §3): one row per valid profile directory, plus the count
214/// of entries that were skipped as unrecognized.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Listing {
217    /// One row per profile directory, sorted by profile name: the profile and the ids of the agents
218    /// materialized for it, in the given `adapters` order. A row may list no agent.
219    pub rows: Vec<(ProfileName, Vec<String>)>,
220    /// Entries of the profiles directory and child directories under a listed profile that were not part
221    /// of any row: non-directories, invalid names and unknown agent directories.
222    pub ignored: usize,
223}
224
225/// Reads what exists on disk, human-readable: each valid profile directory under `<root>/profiles`
226/// becomes a row naming every `adapter` that is materialized for it, in `adapters` order. Purely
227/// read-only: nothing is created, and a missing profiles directory is simply an empty listing.
228///
229/// Entries whose name starts with `.` are skipped and not counted. Anything else that is not a
230/// directory, not valid UTF-8 or not a valid profile name for the host platform is an unrecognized
231/// entry, as is any directory below a listed profile that is not a known agent id.
232pub fn list_profiles(root: &AppRoot, adapters: &[&dyn Adapter]) -> Result<Listing> {
233    let io_context = "could not read the profiles directory".to_owned();
234    let entries = match fs::read_dir(root.profiles_dir()) {
235        Ok(entries) => entries,
236        Err(error) if error.kind() == io::ErrorKind::NotFound => {
237            return Ok(Listing { rows: Vec::new(), ignored: 0 });
238        }
239        Err(source) => return Err(Error::Io { context: io_context, source }),
240    };
241    let mut rows: Vec<(ProfileName, Vec<String>)> = Vec::new();
242    let mut ignored = 0usize;
243    for entry in entries {
244        let entry = entry.map_err(|source| Error::Io { context: io_context.clone(), source })?;
245        let name = entry.file_name();
246        // A name starting with `.` is an editor or OS dropping (*.swp, .DS_Store): it is not part of any
247        // row, and the user did not put it in a profile either, so it is not counted at all.
248        if name.as_encoded_bytes().starts_with(b".") {
249            continue;
250        }
251        // `file_type` does not follow symlinks, so a symlinked profile entry counts as not a directory.
252        if !entry
253            .file_type()
254            .map_err(|source| Error::Io { context: io_context.clone(), source })?
255            .is_dir()
256        {
257            ignored += 1;
258            continue;
259        }
260        let profile = match name
261            .to_str()
262            .map(|name| ProfileName::parse(name, Platform::host()))
263            .transpose()
264        {
265            Ok(Some(profile)) => profile,
266            // Not valid UTF-8 (`Ok(None)`) or a reserved or otherwise invalid name, e.g. the command
267            // words: the user did not create it through this tool, so count and move on.
268            _ => {
269                ignored += 1;
270                continue;
271            }
272        };
273        // `presence` reads the filesystem only and never writes (SP5a design §3); a bare directory with
274        // no config file is not materialized for a file adapter.
275        let agents: Vec<String> = adapters
276            .iter()
277            .filter(|adapter| adapter.presence(root, &profile) == ProfilePresence::Materialized)
278            .map(|adapter| adapter.metadata().id.to_owned())
279            .collect();
280        let directory = root.profiles_dir().join(profile.as_str());
281        // An OS-refused read one level down names the profile directory that failed, not the profiles
282        // directory above it, so a user with many profiles knows which one to look at.
283        let child_context = format!("could not read the profile directory {}", directory.display());
284        ignored += unrecognized_children(&directory, adapters, &child_context)?;
285        rows.push((profile, agents));
286    }
287    // `str`'s `Ord` is byte order, so rows sort by profile name bytes.
288    rows.sort_by(|(a, _), (b, _)| a.as_str().as_bytes().cmp(b.as_str().as_bytes()));
289    Ok(Listing { rows, ignored })
290}
291
292/// The number of child directories of a listed profile directory that are not known agent ids: each one
293/// is an unrecognized entry and the caller's `ignored` grows by one per child. A child file is never
294/// counted — only child directories can be unrecognized — and a child entry whose name starts with `.`
295/// is skipped without counting, mirroring the profiles-directory rule: the same OS litter (*.swp,
296/// .DS_Store) must behave the same one level deeper.
297fn unrecognized_children(
298    directory: &Path,
299    adapters: &[&dyn Adapter],
300    io_context: &str,
301) -> Result<usize> {
302    let entries = match fs::read_dir(directory) {
303        Ok(entries) => entries,
304        // Gone between listing the profile and reading it: nothing unrecognized under a vanished dir.
305        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
306        Err(source) => return Err(Error::Io { context: io_context.to_owned(), source }),
307    };
308    let mut count = 0usize;
309    for entry in entries {
310        let entry = entry.map_err(|source| Error::Io { context: io_context.to_owned(), source })?;
311        if entry.file_name().as_encoded_bytes().starts_with(b".") {
312            continue;
313        }
314        // lstat-based, as at the profiles-directory level: a symlink child is not a directory, so it is
315        // simply not counted — no refusal.
316        if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
317            continue;
318        }
319        if !adapters.iter().any(|adapter| entry.file_name().to_str() == Some(adapter.metadata().id))
320        {
321            count += 1;
322        }
323    }
324    Ok(count)
325}
326
327/// What `<agent> status` and the `status` document report for a profile (SP3 design §7.5).
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub enum Presence {
330    Materialized,
331    Known,
332    Absent,
333    /// A profile whose name differs only by case exists; `existing` is its spelling.
334    Conflict {
335        existing: String,
336    },
337}
338
339impl Presence {
340    /// The human label printed by `status`.
341    pub fn label(&self) -> String {
342        match self {
343            Presence::Materialized => "materialized".to_owned(),
344            Presence::Known => "known".to_owned(),
345            Presence::Absent => "absent".to_owned(),
346            Presence::Conflict { existing } => format!("conflicts with {existing}"),
347        }
348    }
349
350    /// The closed token used in JSON documents.
351    pub fn token(&self) -> &'static str {
352        match self {
353            Presence::Materialized => "materialized",
354            Presence::Known => "known",
355            Presence::Absent => "absent",
356            Presence::Conflict { .. } => "conflict",
357        }
358    }
359}
360
361/// The presence of `profile` for `agent`; a case-only twin is reported before the adapter is asked.
362pub fn presence(root: &AppRoot, agent: &AgentId, profile: &ProfileName) -> Presence {
363    if let Err(Error::ProfileCaseConflict { existing, .. }) = check_case_twins(root, profile) {
364        return Presence::Conflict { existing };
365    }
366    let adapter = adapter::lookup(agent.as_str()).expect("known agents have an adapter");
367    match adapter.presence(root, profile) {
368        ProfilePresence::Materialized => Presence::Materialized,
369        ProfilePresence::Known => Presence::Known,
370        ProfilePresence::Absent => Presence::Absent,
371    }
372}
373
374#[cfg(all(test, debug_assertions))]
375mod tests {
376    use super::*;
377    use crate::adapter::Fake;
378    use crate::name::Platform;
379
380    fn profile(name: &str) -> ProfileName {
381        ProfileName::parse(name, Platform::host()).unwrap()
382    }
383
384    #[test]
385    fn presence_labels_and_tokens_cover_every_variant() {
386        let cases = [
387            (Presence::Materialized, "materialized", "materialized"),
388            (Presence::Known, "known", "known"),
389            (Presence::Absent, "absent", "absent"),
390            (Presence::Conflict { existing: "Mine".into() }, "conflicts with Mine", "conflict"),
391        ];
392        for (presence, label, token) in cases {
393            assert_eq!(presence.label(), label);
394            assert_eq!(presence.token(), token);
395        }
396    }
397
398    /// The outcome reads presence before any write: absent → `Created`, materialized → `AlreadyExists`,
399    /// and a half-written profile — the parent directory present, a declared path missing — is not
400    /// materialized, so it is completed and `Created`.
401    #[test]
402    fn create_outcome_reads_presence_before_anything_is_written() {
403        let dir = tempfile::tempdir().unwrap();
404        let root = AppRoot::from_path(dir.path().to_path_buf());
405        let work = profile("work");
406        let expected = root.profiles_dir().join("work").join("fake");
407
408        let (outcome, path) = create(&root, &Fake, &work).unwrap();
409        assert_eq!(outcome, CreateOutcome::Created);
410        assert_eq!(path, expected);
411        assert!(path.is_dir());
412
413        let (outcome, path) = create(&root, &Fake, &work).unwrap();
414        assert_eq!(outcome, CreateOutcome::AlreadyExists);
415        assert_eq!(path, expected);
416
417        let half = profile("half");
418        std::fs::create_dir_all(root.profiles_dir().join("half")).unwrap();
419        let (outcome, path) = create(&root, &Fake, &half).unwrap();
420        assert_eq!(outcome, CreateOutcome::Created);
421        assert_eq!(path, root.profiles_dir().join("half").join("fake"));
422        assert!(path.is_dir());
423    }
424}