1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CreateOutcome {
24 Created,
26 AlreadyExists,
28}
29
30pub 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
57fn 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
67pub 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 .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
88pub 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 let Ok(metadata) = fs::symlink_metadata(&target) else {
107 return Ok(None); };
109 if metadata.is_symlink() {
115 return Err(symlink_refusal(target));
116 }
117 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
142fn 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
152fn 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#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum DeleteOutcome {
163 Deleted { path: PathBuf, parent_note: Option<String> },
166 Vanished { path: PathBuf },
169}
170
171pub 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 source: io::Error::other(format!(
195 "{error}; the profile may be partially removed, retry the deletion"
196 )),
197 }
198 })?;
199 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#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Listing {
217 pub rows: Vec<(ProfileName, Vec<String>)>,
220 pub ignored: usize,
223}
224
225pub 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 if name.as_encoded_bytes().starts_with(b".") {
249 continue;
250 }
251 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 _ => {
269 ignored += 1;
270 continue;
271 }
272 };
273 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 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 rows.sort_by(|(a, _), (b, _)| a.as_str().as_bytes().cmp(b.as_str().as_bytes()));
289 Ok(Listing { rows, ignored })
290}
291
292fn 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
329pub enum Presence {
330 Materialized,
331 Known,
332 Absent,
333 Conflict {
335 existing: String,
336 },
337}
338
339impl Presence {
340 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 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
361pub 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 #[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}