Skip to main content

agent_skill_installer/
install.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! Installing, updating and removing a skill (D4, D5, D7).
5//!
6//! [`Installer::assess`] looks at every place and says what it would do and
7//! why. [`Installer::apply`] does what the user approved. [`Installer::update`]
8//! is the part that needs no approval: a skill this program installed earlier,
9//! unchanged since, brought up to the running version.
10
11use 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/// The program shipping the skill: its name goes into the record, and its
26/// version decides whether an installed copy is older.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Program {
29    name: String,
30    version: semver::Version,
31}
32
33impl Program {
34    /// A program and its version, such as `env!("CARGO_PKG_VERSION")`.
35    ///
36    /// # Errors
37    /// Returns an error when the version is not semantic versioning.
38    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/// What is in a place under the skill's name.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Found {
50    /// Nothing.
51    Absent,
52    /// A skill this program installed.
53    Ours {
54        /// The version that installed it.
55        version: semver::Version,
56        /// Whether every file is as it was installed.
57        unmodified: bool,
58    },
59    /// Something that cannot be shown to be this program's, and why.
60    Foreign(String),
61}
62
63/// What the installer would do in a place, and why.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum Action {
67    /// Install, once the user agrees.
68    Install,
69    /// Bring an unchanged installed copy up to the running version. Needs no
70    /// approval.
71    Update {
72        /// The version installed now.
73        from: semver::Version,
74    },
75    /// Nothing to do.
76    Nothing(Why),
77    /// Nothing is done; the user should know.
78    Report(Problem),
79}
80
81/// Why nothing is done.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Why {
85    /// The user declined this target.
86    Declined,
87    /// The installed copy is this version.
88    Current,
89    /// The installed copy is newer than the running program, which never
90    /// downgrades it.
91    Newer(semver::Version),
92    /// The place is not a planned target and holds nothing of this program.
93    NotPlanned,
94}
95
96/// Why something needs the user's attention.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum Problem {
100    /// The installed copy was changed after installation. Only an explicit
101    /// overwrite replaces it.
102    Modified,
103    /// A skill of this name that is not this program's, or something that is
104    /// not a skill directory at all. Never replaced.
105    Foreign(String),
106}
107
108/// One place, what is there and what would happen.
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub struct Step {
112    /// The physical skills directory.
113    pub place: PathBuf,
114    /// The skill's directory inside it.
115    pub skill_dir: PathBuf,
116    /// Whether the plan chose this place.
117    pub planned: bool,
118    /// The installed tools reading a skill here, when planned.
119    pub tools: Vec<String>,
120    /// The root of the git working tree the place lies in, if any.
121    pub git_root: Option<PathBuf>,
122    /// What is there now.
123    pub found: Found,
124    /// What would happen.
125    pub action: Action,
126}
127
128/// A plan and the step for every place it concerns.
129#[derive(Debug, Clone, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct Assessment {
132    /// Where the skill should go.
133    pub plan: Plan,
134    /// One step per planned target, then one per further place holding this
135    /// program's skill.
136    pub steps: Vec<Step>,
137}
138
139impl Assessment {
140    /// Steps waiting for the user's approval.
141    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/// What the user agreed to.
149#[derive(Debug, Clone, Default, PartialEq, Eq)]
150#[non_exhaustive]
151pub struct Approval {
152    /// Places to install into.
153    pub install: Vec<PathBuf>,
154    /// Places whose modified copy is to be replaced anyway.
155    pub overwrite: Vec<PathBuf>,
156}
157
158impl Approval {
159    /// Agreement to install into these places.
160    #[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    /// The same approval, also replacing the modified copies in these places.
169    #[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/// What happened in one place.
177#[derive(Debug, Clone, PartialEq, Eq)]
178#[non_exhaustive]
179pub struct Outcome {
180    /// The physical skills directory.
181    pub place: PathBuf,
182    /// What happened there.
183    pub done: Done,
184}
185
186/// What happened.
187#[derive(Debug, Clone, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum Done {
190    /// Installed.
191    Installed,
192    /// Updated from an older version.
193    Updated {
194        /// The version that was there.
195        from: semver::Version,
196    },
197    /// A modified copy was replaced on explicit request.
198    Overwritten,
199    /// Removed.
200    Removed,
201    /// Nothing was written: the place changed after the assessment, and what
202    /// is there now was put back untouched. Assessing again shows what it is.
203    Changed,
204    /// Writing failed; the place is as it was before.
205    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/// A skill, the program it belongs to, and the machine to put it on.
222#[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    /// An installer for one skill of one program.
232    #[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    /// What would happen, place by place. Writes nothing.
248    ///
249    /// # Errors
250    /// Returns an error only when no plan can be made.
251    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    /// Install where approved, overwrite where explicitly asked, and update
300    /// whatever needs no approval.
301    #[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    /// Bring every unchanged copy this program installed up to the running
330    /// version. Installs nothing new and needs no approval.
331    ///
332    /// # Errors
333    /// Returns an error only when no plan can be made.
334    pub fn update(&self) -> Result<Vec<Outcome>, PlanError> {
335        // Declines do not matter here: only an install asks for consent, and
336        // an empty approval installs nothing.
337        let assessment = self.assess_with(&|_| false)?;
338        Ok(self.apply(&assessment, &Approval::default()))
339    }
340
341    /// Remove every unchanged copy this program installed. A modified copy
342    /// and anything not this program's is left where it is.
343    ///
344    /// # Errors
345    /// Returns an error only when no plan can be made.
346    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            // Writing through it would leave the place; and a link somebody
378            // set up is theirs, not ours (D7).
379            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        // The assessment may be old: a person could have edited the skill, or
429        // put one of their own in an empty place, since. What was there is
430        // checked once it has been moved aside, so nothing changed before that
431        // instant is overwritten.
432        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/// Where a replacement stands, for a check or a test that interrupts it.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum Stage {
451    /// The new version is written next to the place; nothing moved yet.
452    Staged,
453    /// The old version has been moved aside; the new one is not in place.
454    OldMovedAside,
455}
456
457/// A sibling of the skills directory for staging, so no tool scanning the
458/// skills directory ever sees a half-written skill as one of its own.
459/// How old a staging directory has to be before it counts as left behind by
460/// a process that died. No replacement takes this long.
461const STALE: std::time::Duration = std::time::Duration::from_hours(1);
462
463/// Remove staging directories of this skill that a crashed or killed process
464/// left beside the skills directory. Their age is in their name; a young one
465/// may belong to a write going on right now and is left alone.
466fn 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        // `<pid>-<stamp>.<kind>`; the skill's own name may hold hyphens, but
483        // it is already stripped.
484        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
508/// Put `files` at `skill_dir`, replacing what is there in one step (D7).
509///
510/// The new version is written beside the skills directory and renamed into
511/// place. A tool reading at the same moment finds the old version, the new
512/// one, or for an instant none, never a mixture. On any failure the old
513/// version is put back and the staging removed.
514///
515/// `interrupt` is called at each stage with the directory concerned: the
516/// staged new version, then the old one moved aside, which no longer moves
517/// under anyone's hands and is what a check should look at. An error there
518/// stops the replacement.
519pub 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    // The new version is in place. Failing to delete the old one changes
558    // nothing a tool sees; the next write sweeps it away.
559    if had_old {
560        let _ = fs::remove_dir_all(&old);
561    }
562    Ok(())
563}
564
565/// Take a skill away in one step: moved aside first, then deleted, so a tool
566/// never reads a half-deleted skill.
567fn 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    // Gone for every tool once moved aside; a failed delete is swept later.
573    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    /// M3-A3: interrupted at either stage, the old version is intact and no
594    /// staging is left behind.
595    #[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    /// M3-A3, counter-probe: uninterrupted, the new version is in place and
631    /// nothing is left over.
632    #[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    /// A staging directory older than an hour, left by a process that died,
647    /// is swept by the next write; a young one may be a write going on now
648    /// and stays.
649    #[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    /// Once the new version is in place, failing to delete the old one is not
676    /// a failure: no tool sees the difference, and the next write sweeps it.
677    #[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    /// Staging happens beside the skills directory, never inside it, so a
711    /// tool scanning the directory cannot pick up a half-written copy.
712    #[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}