1use std::collections::BTreeMap;
12use std::fmt;
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use crate::catalog::Catalog;
18use crate::consent::Consent;
19use crate::env::Environment;
20use crate::plan::{Plan, PlanError, existing_places, plan};
21use crate::record;
22use crate::resolve;
23use crate::skill::{SKILL_FILE, Skill};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Program {
29 name: String,
30 version: semver::Version,
31}
32
33impl Program {
34 pub fn new(name: impl Into<String>, version: &str) -> Result<Self, semver::Error> {
39 Ok(Self {
40 name: name.into(),
41 version: semver::Version::parse(version)?,
42 })
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Found {
50 Absent,
52 Ours {
54 version: semver::Version,
56 unmodified: bool,
58 },
59 Foreign(String),
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum Action {
67 Install,
69 Update {
72 from: semver::Version,
74 },
75 Nothing(Why),
77 Report(Problem),
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Why {
85 Declined,
87 Current,
89 Newer(semver::Version),
92 NotPlanned,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum Problem {
100 Modified,
103 Foreign(String),
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub struct Step {
112 pub place: PathBuf,
114 pub skill_dir: PathBuf,
116 pub planned: bool,
118 pub tools: Vec<String>,
120 pub git_root: Option<PathBuf>,
122 pub found: Found,
124 pub action: Action,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct Assessment {
132 pub plan: Plan,
134 pub steps: Vec<Step>,
137}
138
139impl Assessment {
140 pub fn awaiting_approval(&self) -> impl Iterator<Item = &Step> {
142 self.steps
143 .iter()
144 .filter(|step| step.action == Action::Install)
145 }
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
150#[non_exhaustive]
151pub struct Approval {
152 pub install: Vec<PathBuf>,
154 pub overwrite: Vec<PathBuf>,
156}
157
158impl Approval {
159 #[must_use]
161 pub fn install(places: impl IntoIterator<Item = PathBuf>) -> Self {
162 Self {
163 install: places.into_iter().collect(),
164 overwrite: Vec::new(),
165 }
166 }
167
168 #[must_use]
170 pub fn with_overwrite(mut self, places: impl IntoIterator<Item = PathBuf>) -> Self {
171 self.overwrite.extend(places);
172 self
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178#[non_exhaustive]
179pub struct Outcome {
180 pub place: PathBuf,
182 pub done: Done,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum Done {
190 Installed,
192 Updated {
194 from: semver::Version,
196 },
197 Overwritten,
199 Removed,
201 Changed,
204 Failed(String),
206}
207
208impl fmt::Display for Done {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 match self {
211 Self::Installed => f.write_str("installed"),
212 Self::Updated { from } => write!(f, "updated from {from}"),
213 Self::Overwritten => f.write_str("overwritten"),
214 Self::Removed => f.write_str("removed"),
215 Self::Changed => f.write_str("left alone: changed since the assessment"),
216 Self::Failed(why) => write!(f, "failed: {why}"),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy)]
223pub struct Installer<'a> {
224 catalog: &'a Catalog,
225 env: &'a Environment,
226 skill: &'a Skill,
227 program: &'a Program,
228}
229
230impl<'a> Installer<'a> {
231 #[must_use]
233 pub const fn new(
234 catalog: &'a Catalog,
235 env: &'a Environment,
236 skill: &'a Skill,
237 program: &'a Program,
238 ) -> Self {
239 Self {
240 catalog,
241 env,
242 skill,
243 program,
244 }
245 }
246
247 pub fn assess(&self, consent: &Consent) -> Result<Assessment, PlanError> {
252 self.assess_with(&|place| consent.is_declined(place))
253 }
254
255 fn assess_with(&self, declined: &dyn Fn(&Path) -> bool) -> Result<Assessment, PlanError> {
256 let plan = plan(self.catalog, self.env)?;
257 let mut steps: Vec<Step> = plan
258 .targets
259 .iter()
260 .map(|target| {
261 let skill_dir = target.path.join(self.skill.name());
262 let found = self.found(&skill_dir);
263 let action = self.action(&found, true, declined(&target.path));
264 Step {
265 place: target.path.clone(),
266 skill_dir,
267 planned: true,
268 tools: target.tools.clone(),
269 git_root: target.git_root.clone(),
270 found,
271 action,
272 }
273 })
274 .collect();
275
276 for place in existing_places(self.catalog, self.env) {
277 if steps.iter().any(|step| step.place == place) {
278 continue;
279 }
280 let skill_dir = place.join(self.skill.name());
281 let found = self.found(&skill_dir);
282 if found == Found::Absent {
283 continue;
284 }
285 let action = self.action(&found, false, false);
286 steps.push(Step {
287 git_root: resolve::git_root(&place),
288 place,
289 skill_dir,
290 planned: false,
291 tools: Vec::new(),
292 found,
293 action,
294 });
295 }
296 Ok(Assessment { plan, steps })
297 }
298
299 #[must_use]
302 pub fn apply(&self, assessment: &Assessment, approval: &Approval) -> Vec<Outcome> {
303 assessment
304 .steps
305 .iter()
306 .filter_map(|step| {
307 let done = match &step.action {
308 Action::Install if approval.install.contains(&step.place) => {
309 self.write(step, Done::Installed)
310 }
311 Action::Update { from } => {
312 self.write(step, Done::Updated { from: from.clone() })
313 }
314 Action::Report(Problem::Modified)
315 if approval.overwrite.contains(&step.place) =>
316 {
317 self.write(step, Done::Overwritten)
318 }
319 _ => return None,
320 };
321 Some(Outcome {
322 place: step.place.clone(),
323 done,
324 })
325 })
326 .collect()
327 }
328
329 pub fn update(&self) -> Result<Vec<Outcome>, PlanError> {
335 let assessment = self.assess_with(&|_| false)?;
338 Ok(self.apply(&assessment, &Approval::default()))
339 }
340
341 pub fn remove(&self) -> Result<Vec<Outcome>, PlanError> {
347 let assessment = self.assess_with(&|_| false)?;
348 Ok(assessment
349 .steps
350 .iter()
351 .filter(|step| {
352 matches!(
353 step.found,
354 Found::Ours {
355 unmodified: true,
356 ..
357 }
358 )
359 })
360 .map(|step| Outcome {
361 place: step.place.clone(),
362 done: match remove_dir(&step.skill_dir) {
363 Ok(()) => Done::Removed,
364 Err(error) => Done::Failed(error.to_string()),
365 },
366 })
367 .collect())
368 }
369
370 fn found(&self, skill_dir: &Path) -> Found {
371 let meta = match fs::symlink_metadata(skill_dir) {
372 Ok(meta) => meta,
373 Err(error) if error.kind() == io::ErrorKind::NotFound => return Found::Absent,
374 Err(error) => return Found::Foreign(error.to_string()),
375 };
376 if meta.file_type().is_symlink() {
377 return Found::Foreign("is a symlink".to_string());
380 }
381 if !meta.is_dir() {
382 return Found::Foreign("is not a directory".to_string());
383 }
384 let Ok(skill_md) = fs::read(skill_dir.join(SKILL_FILE)) else {
385 return Found::Foreign(format!("has no readable {SKILL_FILE}"));
386 };
387 let Some(record) = record::parse(&skill_md) else {
388 return Found::Foreign("carries no installation record".to_string());
389 };
390 if record.installed_by != self.program.name {
391 return Found::Foreign(format!("was installed by {}", record.installed_by));
392 }
393 let Ok(version) = semver::Version::parse(&record.installed_version) else {
394 return Found::Foreign("has an unreadable version in its record".to_string());
395 };
396 let unmodified = record::read_dir(skill_dir)
397 .is_ok_and(|files| record::digest(&files) == record.content_digest);
398 Found::Ours {
399 version,
400 unmodified,
401 }
402 }
403
404 fn action(&self, found: &Found, planned: bool, declined: bool) -> Action {
405 match found {
406 Found::Absent if !planned => Action::Nothing(Why::NotPlanned),
407 Found::Absent if declined => Action::Nothing(Why::Declined),
408 Found::Absent => Action::Install,
409 Found::Ours {
410 unmodified: false, ..
411 } => Action::Report(Problem::Modified),
412 Found::Ours { version, .. } if *version < self.program.version => Action::Update {
413 from: version.clone(),
414 },
415 Found::Ours { version, .. } if *version > self.program.version => {
416 Action::Nothing(Why::Newer(version.clone()))
417 }
418 Found::Ours { .. } => Action::Nothing(Why::Current),
419 Found::Foreign(why) => Action::Report(Problem::Foreign(why.clone())),
420 }
421 }
422
423 fn write(&self, step: &Step, done: Done) -> Done {
424 let files = record::sealed(
425 self.skill
426 .files_with_record(&self.program.name, &self.program.version.to_string()),
427 );
428 let mut changed = false;
433 let result = replace_dir(&step.place, &step.skill_dir, &files, &mut |stage, path| {
434 if stage == Stage::OldMovedAside && self.found(path) != step.found {
435 changed = true;
436 return Err(io::Error::other("changed since the assessment"));
437 }
438 Ok(())
439 });
440 match result {
441 Ok(()) => done,
442 Err(_) if changed => Done::Changed,
443 Err(error) => Done::Failed(error.to_string()),
444 }
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum Stage {
451 Staged,
453 OldMovedAside,
455}
456
457const STALE: std::time::Duration = std::time::Duration::from_hours(1);
462
463fn sweep_stale(place: &Path, skill_dir: &Path) {
467 let (Some(parent), Some(name)) = (place.parent(), skill_dir.file_name()) else {
468 return;
469 };
470 let prefix = format!(".asi-{}-", name.to_string_lossy());
471 let Ok(entries) = fs::read_dir(parent) else {
472 return;
473 };
474 let now = std::time::SystemTime::now()
475 .duration_since(std::time::UNIX_EPOCH)
476 .map_or(0, |since| since.as_nanos());
477 for entry in entries.flatten() {
478 let file_name = entry.file_name();
479 let Some(rest) = file_name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
480 continue;
481 };
482 let stamp = rest
485 .rsplit_once('.')
486 .and_then(|(head, _)| head.rsplit_once('-'))
487 .and_then(|(_, stamp)| stamp.parse::<u128>().ok());
488 if stamp.is_some_and(|stamp| now.saturating_sub(stamp) > STALE.as_nanos()) {
489 let _ = fs::remove_dir_all(entry.path());
490 }
491 }
492}
493
494fn staging(place: &Path, skill_dir: &Path, kind: &str) -> io::Result<PathBuf> {
495 let parent = place.parent().ok_or_else(|| {
496 io::Error::new(
497 io::ErrorKind::InvalidInput,
498 "a skills directory without a parent",
499 )
500 })?;
501 let name = skill_dir.file_name().unwrap_or_default().to_string_lossy();
502 let stamp = std::time::SystemTime::now()
503 .duration_since(std::time::UNIX_EPOCH)
504 .map_or(0, |since| since.as_nanos());
505 Ok(parent.join(format!(".asi-{name}-{}-{stamp}.{kind}", std::process::id())))
506}
507
508pub fn replace_dir(
520 place: &Path,
521 skill_dir: &Path,
522 files: &BTreeMap<PathBuf, Vec<u8>>,
523 interrupt: &mut dyn FnMut(Stage, &Path) -> io::Result<()>,
524) -> io::Result<()> {
525 sweep_stale(place, skill_dir);
526 fs::create_dir_all(place)?;
527 let new = staging(place, skill_dir, "new")?;
528 let written = (|| {
529 for (relative, content) in files {
530 let path = new.join(relative);
531 if let Some(parent) = path.parent() {
532 fs::create_dir_all(parent)?;
533 }
534 fs::write(path, content)?;
535 }
536 interrupt(Stage::Staged, &new)
537 })();
538 if let Err(error) = written {
539 let _ = fs::remove_dir_all(&new);
540 return Err(error);
541 }
542
543 let old = staging(place, skill_dir, "old")?;
544 let had_old = fs::symlink_metadata(skill_dir).is_ok();
545 if had_old && let Err(error) = fs::rename(skill_dir, &old) {
546 let _ = fs::remove_dir_all(&new);
547 return Err(error);
548 }
549 let moved = interrupt(Stage::OldMovedAside, &old).and_then(|()| fs::rename(&new, skill_dir));
550 if let Err(error) = moved {
551 if had_old {
552 let _ = fs::rename(&old, skill_dir);
553 }
554 let _ = fs::remove_dir_all(&new);
555 return Err(error);
556 }
557 if had_old {
560 let _ = fs::remove_dir_all(&old);
561 }
562 Ok(())
563}
564
565fn remove_dir(skill_dir: &Path) -> io::Result<()> {
568 let place = skill_dir.parent().unwrap_or(skill_dir);
569 sweep_stale(place, skill_dir);
570 let aside = staging(place, skill_dir, "gone")?;
571 fs::rename(skill_dir, &aside)?;
572 let _ = fs::remove_dir_all(aside);
574 Ok(())
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 fn files(content: &str) -> BTreeMap<PathBuf, Vec<u8>> {
582 BTreeMap::from([(PathBuf::from(SKILL_FILE), content.as_bytes().to_vec())])
583 }
584
585 fn leftovers(parent: &Path) -> Vec<String> {
586 fs::read_dir(parent)
587 .expect("read")
588 .filter_map(|entry| entry.ok()?.file_name().into_string().ok())
589 .filter(|name| name.starts_with(".asi-"))
590 .collect()
591 }
592
593 #[test]
596 fn an_interrupted_replacement_leaves_the_old_skill_intact() {
597 for stop_at in [Stage::Staged, Stage::OldMovedAside] {
598 let home = tempfile::tempdir().expect("home");
599 let place = home.path().join("tool/skills");
600 let skill_dir = place.join("devserial");
601 replace_dir(&place, &skill_dir, &files("old"), &mut |_, _| Ok(())).expect("first");
602
603 let result = replace_dir(&place, &skill_dir, &files("new"), &mut |stage, _| {
604 if stage == stop_at {
605 Err(io::Error::other("interrupted"))
606 } else {
607 Ok(())
608 }
609 });
610
611 assert!(result.is_err(), "{stop_at:?}");
612 assert_eq!(
613 fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("old"),
614 "old",
615 "{stop_at:?}"
616 );
617 assert_eq!(
618 leftovers(&home.path().join("tool")),
619 Vec::<String>::new(),
620 "{stop_at:?}"
621 );
622 assert_eq!(
623 fs::read_dir(&place).expect("read").count(),
624 1,
625 "{stop_at:?}"
626 );
627 }
628 }
629
630 #[test]
633 fn an_uninterrupted_replacement_leaves_only_the_new_skill() {
634 let home = tempfile::tempdir().expect("home");
635 let place = home.path().join("tool/skills");
636 let skill_dir = place.join("devserial");
637 replace_dir(&place, &skill_dir, &files("old"), &mut |_, _| Ok(())).expect("first");
638 replace_dir(&place, &skill_dir, &files("new"), &mut |_, _| Ok(())).expect("second");
639 assert_eq!(
640 fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("new"),
641 "new"
642 );
643 assert_eq!(leftovers(&home.path().join("tool")), Vec::<String>::new());
644 }
645
646 #[test]
650 fn stale_staging_is_swept_and_young_staging_kept() {
651 let home = tempfile::tempdir().expect("home");
652 let parent = home.path().join("tool");
653 let place = parent.join("skills");
654 let skill_dir = place.join("devserial");
655 fs::create_dir_all(&parent).expect("parent");
656 let now = std::time::SystemTime::now()
657 .duration_since(std::time::UNIX_EPOCH)
658 .expect("clock")
659 .as_nanos();
660 let old = now - std::time::Duration::from_hours(2).as_nanos();
661 let young = now - std::time::Duration::from_mins(1).as_nanos();
662 let stale = parent.join(format!(".asi-devserial-1-{old}.new"));
663 let fresh = parent.join(format!(".asi-devserial-1-{young}.new"));
664 let other = parent.join(format!(".asi-other-1-{old}.new"));
665 for dir in [&stale, &fresh, &other] {
666 fs::create_dir_all(dir).expect("staging");
667 }
668
669 replace_dir(&place, &skill_dir, &files("x"), &mut |_, _| Ok(())).expect("replace");
670 assert!(!stale.exists(), "a stale staging directory was left");
671 assert!(fresh.exists(), "a young staging directory was swept");
672 assert!(other.exists(), "another skill's staging was swept");
673 }
674
675 #[cfg(unix)]
678 #[test]
679 fn a_failed_cleanup_does_not_fail_the_replacement() {
680 use std::os::unix::fs::PermissionsExt as _;
681 let home = tempfile::tempdir().expect("home");
682 let place = home.path().join("tool/skills");
683 let skill_dir = place.join("devserial");
684 let mut first = files("old");
685 first.insert(PathBuf::from("locked/file.md"), b"x".to_vec());
686 replace_dir(&place, &skill_dir, &first, &mut |_, _| Ok(())).expect("first");
687 let locked = skill_dir.join("locked");
688 fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).expect("lock");
689
690 let result = replace_dir(&place, &skill_dir, &files("new"), &mut |_, _| Ok(()));
691 let left = leftovers(&home.path().join("tool"));
692 for dir in &left {
693 let _ = fs::set_permissions(
694 home.path().join("tool").join(dir).join("locked"),
695 fs::Permissions::from_mode(0o755),
696 );
697 }
698 result.expect("the replacement reported a failure");
699 assert_eq!(
700 fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("new"),
701 "new"
702 );
703 assert_eq!(
704 left.len(),
705 1,
706 "the old copy should be left for the sweep: {left:?}"
707 );
708 }
709
710 #[test]
713 fn staging_is_beside_the_skills_directory() {
714 let home = tempfile::tempdir().expect("home");
715 let place = home.path().join("tool/skills");
716 let skill_dir = place.join("devserial");
717 let mut seen = Vec::new();
718 replace_dir(&place, &skill_dir, &files("x"), &mut |_, _| {
719 seen.extend(
720 fs::read_dir(&place)
721 .expect("read")
722 .filter_map(Result::ok)
723 .map(|e| e.file_name()),
724 );
725 Ok(())
726 })
727 .expect("replace");
728 assert!(
729 seen.is_empty(),
730 "the skills directory held {seen:?} while staging"
731 );
732 }
733}