Skip to main content

agent_profile/
repositories.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 `repositories` report: every stored repository mapping, classified by whether it still applies (SP5b
8//! design §4). Classification never fails; a mapping that cannot be examined is reported, not raised.
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::io;
13use std::path::Path;
14
15use crate::adapter;
16use crate::config::{AppRoot, Config, is_host_form_key};
17use crate::error::Error;
18use crate::name::{AgentId, ProfileName};
19use crate::repo::{self, Discovery};
20
21/// What became of a stored mapping key.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum State {
24    Active,
25    Moved,
26    NotARepository,
27    Missing,
28    Unreadable,
29    Foreign,
30}
31
32impl State {
33    pub fn token(self) -> &'static str {
34        match self {
35            State::Active => "active",
36            State::Moved => "moved",
37            State::NotARepository => "not_a_repository",
38            State::Missing => "missing",
39            State::Unreadable => "unreadable",
40            State::Foreign => "foreign",
41        }
42    }
43}
44
45/// A profile a mapping names, and whether its directory exists.
46pub struct ProfileRef {
47    pub name: ProfileName,
48    pub exists: bool,
49}
50
51/// A per-agent profile a mapping names.
52pub struct AgentRef {
53    pub profile: ProfileName,
54    pub exists: bool,
55    pub known: bool,
56}
57
58/// One stored mapping and its classification.
59pub struct Entry {
60    pub key: String,
61    pub state: State,
62    pub detail: Option<String>,
63    pub profile: Option<ProfileRef>,
64    pub agents: BTreeMap<AgentId, AgentRef>,
65}
66
67/// Classifies a stored key, in the design's order of decision (SP5b design §4).
68pub fn classify(key: &str) -> (State, Option<String>) {
69    if !is_host_form_key(key) {
70        return (State::Foreign, None);
71    }
72    #[cfg(windows)]
73    {
74        use std::path::{Component, Prefix};
75        let allowed = matches!(
76            Path::new(key).components().next(),
77            Some(Component::Prefix(prefix)) if matches!(
78                prefix.kind(),
79                Prefix::Disk(_) | Prefix::VerbatimDisk(_) | Prefix::UNC(..) | Prefix::VerbatimUNC(..)
80            )
81        );
82        if !allowed {
83            return (State::Unreadable, Some("device path refused".to_owned()));
84        }
85    }
86    match fs::metadata(key) {
87        Ok(metadata) if metadata.is_dir() => {}
88        Ok(_) => return (State::Unreadable, Some("not a directory".to_owned())),
89        Err(error) if error.kind() == io::ErrorKind::NotFound => return (State::Missing, None),
90        Err(error) => return (State::Unreadable, Some(error.to_string())),
91    }
92    match repo::discover(Path::new(key)) {
93        Err(Error::Repository { reason, .. }) => (State::Unreadable, Some(reason)),
94        Err(error) => (State::Unreadable, Some(error.to_string())),
95        Ok(Discovery::NotInRepository) => (State::NotARepository, None),
96        Ok(Discovery::Repository(root)) if root == Path::new(key) => (State::Active, None),
97        Ok(Discovery::Repository(root)) => (State::Moved, Some(root.display().to_string())),
98    }
99}
100
101/// One entry per stored mapping, in stored-key order. Never fails.
102pub fn report(root: &AppRoot, config: &Config) -> Vec<Entry> {
103    let profiles = root.profiles_dir();
104    let exists = |name: &ProfileName| profiles.join(name.as_str()).is_dir();
105    config
106        .mappings()
107        .map(|(path, mapping)| {
108            let key = path.to_string_lossy().into_owned();
109            let (state, detail) = classify(&key);
110            Entry {
111                key,
112                state,
113                detail,
114                profile: mapping
115                    .profile
116                    .as_ref()
117                    .map(|name| ProfileRef { name: name.clone(), exists: exists(name) }),
118                agents: mapping
119                    .agents
120                    .iter()
121                    .map(|(id, profile)| {
122                        (
123                            id.clone(),
124                            AgentRef {
125                                profile: profile.clone(),
126                                exists: exists(profile),
127                                known: adapter::lookup(id.as_str()).is_some(),
128                            },
129                        )
130                    })
131                    .collect(),
132            }
133        })
134        .collect()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn scratch() -> (tempfile::TempDir, std::path::PathBuf) {
142        let dir = tempfile::tempdir().unwrap();
143        let path = repo::canonical(dir.path()).unwrap();
144        (dir, path)
145    }
146
147    fn git_dir(path: &Path) {
148        fs::create_dir_all(path.join(".git")).unwrap();
149        fs::write(path.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
150    }
151
152    fn s(path: &Path) -> &str {
153        path.to_str().unwrap()
154    }
155
156    #[test]
157    fn classify_orders_foreign_missing_unreadable_not_a_repository_active_and_moved() {
158        let (_dir, base) = scratch();
159        let acme = base.join("acme");
160        git_dir(&acme);
161        let sub = acme.join("sub");
162        fs::create_dir_all(&sub).unwrap();
163        fs::create_dir_all(base.join("plain")).unwrap();
164        fs::write(base.join("file.txt"), "x").unwrap();
165        assert_eq!(classify(s(&acme)), (State::Active, None));
166        assert_eq!(classify(s(&sub)), (State::Moved, Some(acme.display().to_string())));
167        assert_eq!(classify(s(&base.join("plain"))), (State::NotARepository, None));
168        assert_eq!(classify(s(&base.join("none"))), (State::Missing, None));
169        assert_eq!(
170            classify(s(&base.join("file.txt"))),
171            (State::Unreadable, Some("not a directory".to_owned()))
172        );
173        let other = if cfg!(windows) { "/srv/acme" } else { r"C:\src\acme" };
174        assert_eq!(classify(other), (State::Foreign, None));
175    }
176
177    #[cfg(unix)]
178    #[test]
179    fn classify_treats_a_dangling_symlink_as_missing_and_a_broken_git_as_unreadable() {
180        let (_dir, base) = scratch();
181        std::os::unix::fs::symlink(base.join("gone"), base.join("dangling")).unwrap();
182        assert_eq!(classify(s(&base.join("dangling"))), (State::Missing, None));
183        let broken = base.join("broken");
184        fs::create_dir_all(&broken).unwrap();
185        fs::write(broken.join(".git"), "gitdir: /nonexistent/x\n").unwrap();
186        let (state, detail) = classify(s(&broken));
187        assert_eq!(state, State::Unreadable);
188        assert!(detail.is_some_and(|reason| !reason.is_empty()));
189    }
190
191    #[cfg(windows)]
192    #[test]
193    fn a_verbatim_disk_key_is_classified_like_a_plain_disk_key() {
194        assert_eq!(classify(r"\\?\C:\agent-profile-nonexistent\x"), (State::Missing, None));
195    }
196
197    #[cfg(windows)]
198    #[test]
199    fn classify_refuses_a_device_namespace_key_before_touching_it() {
200        assert_eq!(
201            classify(r"\\.\pipe\agent-profile-test"),
202            (State::Unreadable, Some("device path refused".into()))
203        );
204    }
205
206    #[test]
207    fn report_marks_missing_profile_directories_unknown_agents_and_keeps_stored_order() {
208        let (_dir, base) = scratch();
209        let acme = base.join("acme");
210        git_dir(&acme);
211        let zzz = base.join("zzz-missing");
212        let home = tempfile::tempdir().unwrap();
213        let root = AppRoot::from_path(home.path().to_path_buf());
214        fs::write(
215            root.config_path(),
216            format!(
217                "[repositories.'{}']\nprofile = \"work\"\nagents = {{ fake = \"mine\", nosuch = \"work\" }}\n\
218                 [repositories.'{}']\nprofile = \"gone\"\n",
219                acme.display(),
220                zzz.display()
221            ),
222        )
223        .unwrap();
224        fs::create_dir_all(root.profiles_dir().join("work").join("fake")).unwrap();
225        let config = Config::load(&root).unwrap();
226        let entries = report(&root, &config);
227        assert_eq!(entries.len(), 2);
228        assert_eq!(entries[0].key, acme.display().to_string());
229        assert_eq!(entries[0].state, State::Active);
230        let profile = entries[0].profile.as_ref().unwrap();
231        assert_eq!((profile.name.as_str(), profile.exists), ("work", true));
232        let fake = &entries[0].agents[&AgentId::parse("fake").unwrap()];
233        assert_eq!((fake.profile.as_str(), fake.exists, fake.known), ("mine", false, true));
234        let nosuch = &entries[0].agents[&AgentId::parse("nosuch").unwrap()];
235        assert_eq!((nosuch.profile.as_str(), nosuch.exists, nosuch.known), ("work", true, false));
236        assert_eq!(entries[1].state, State::Missing);
237        let gone = entries[1].profile.as_ref().unwrap();
238        assert_eq!((gone.name.as_str(), gone.exists), ("gone", false));
239    }
240}