1use 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#[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
45pub struct ProfileRef {
47 pub name: ProfileName,
48 pub exists: bool,
49}
50
51pub struct AgentRef {
53 pub profile: ProfileName,
54 pub exists: bool,
55 pub known: bool,
56}
57
58pub 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
67pub 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
101pub 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}