use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::catalog::Catalog;
use crate::consent::Consent;
use crate::env::Environment;
use crate::plan::{Plan, PlanError, existing_places, plan};
use crate::record;
use crate::resolve;
use crate::skill::{SKILL_FILE, Skill};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Program {
name: String,
version: semver::Version,
}
impl Program {
pub fn new(name: impl Into<String>, version: &str) -> Result<Self, semver::Error> {
Ok(Self {
name: name.into(),
version: semver::Version::parse(version)?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Found {
Absent,
Ours {
version: semver::Version,
unmodified: bool,
},
Foreign(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Action {
Install,
Update {
from: semver::Version,
},
Nothing(Why),
Report(Problem),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Why {
Declined,
Current,
Newer(semver::Version),
NotPlanned,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Problem {
Modified,
Foreign(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Step {
pub place: PathBuf,
pub skill_dir: PathBuf,
pub planned: bool,
pub tools: Vec<String>,
pub git_root: Option<PathBuf>,
pub found: Found,
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Assessment {
pub plan: Plan,
pub steps: Vec<Step>,
}
impl Assessment {
pub fn awaiting_approval(&self) -> impl Iterator<Item = &Step> {
self.steps
.iter()
.filter(|step| step.action == Action::Install)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Approval {
pub install: Vec<PathBuf>,
pub overwrite: Vec<PathBuf>,
}
impl Approval {
#[must_use]
pub fn install(places: impl IntoIterator<Item = PathBuf>) -> Self {
Self {
install: places.into_iter().collect(),
overwrite: Vec::new(),
}
}
#[must_use]
pub fn with_overwrite(mut self, places: impl IntoIterator<Item = PathBuf>) -> Self {
self.overwrite.extend(places);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Outcome {
pub place: PathBuf,
pub done: Done,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Done {
Installed,
Updated {
from: semver::Version,
},
Overwritten,
Removed,
Changed,
Failed(String),
}
impl fmt::Display for Done {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Installed => f.write_str("installed"),
Self::Updated { from } => write!(f, "updated from {from}"),
Self::Overwritten => f.write_str("overwritten"),
Self::Removed => f.write_str("removed"),
Self::Changed => f.write_str("left alone: changed since the assessment"),
Self::Failed(why) => write!(f, "failed: {why}"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Installer<'a> {
catalog: &'a Catalog,
env: &'a Environment,
skill: &'a Skill,
program: &'a Program,
}
impl<'a> Installer<'a> {
#[must_use]
pub const fn new(
catalog: &'a Catalog,
env: &'a Environment,
skill: &'a Skill,
program: &'a Program,
) -> Self {
Self {
catalog,
env,
skill,
program,
}
}
pub fn assess(&self, consent: &Consent) -> Result<Assessment, PlanError> {
self.assess_with(&|place| consent.is_declined(place))
}
fn assess_with(&self, declined: &dyn Fn(&Path) -> bool) -> Result<Assessment, PlanError> {
let plan = plan(self.catalog, self.env)?;
let mut steps: Vec<Step> = plan
.targets
.iter()
.map(|target| {
let skill_dir = target.path.join(self.skill.name());
let found = self.found(&skill_dir);
let action = self.action(&found, true, declined(&target.path));
Step {
place: target.path.clone(),
skill_dir,
planned: true,
tools: target.tools.clone(),
git_root: target.git_root.clone(),
found,
action,
}
})
.collect();
for place in existing_places(self.catalog, self.env) {
if steps.iter().any(|step| step.place == place) {
continue;
}
let skill_dir = place.join(self.skill.name());
let found = self.found(&skill_dir);
if found == Found::Absent {
continue;
}
let action = self.action(&found, false, false);
steps.push(Step {
git_root: resolve::git_root(&place),
place,
skill_dir,
planned: false,
tools: Vec::new(),
found,
action,
});
}
Ok(Assessment { plan, steps })
}
#[must_use]
pub fn apply(&self, assessment: &Assessment, approval: &Approval) -> Vec<Outcome> {
assessment
.steps
.iter()
.filter_map(|step| {
let done = match &step.action {
Action::Install if approval.install.contains(&step.place) => {
self.write(step, Done::Installed)
}
Action::Update { from } => {
self.write(step, Done::Updated { from: from.clone() })
}
Action::Report(Problem::Modified)
if approval.overwrite.contains(&step.place) =>
{
self.write(step, Done::Overwritten)
}
_ => return None,
};
Some(Outcome {
place: step.place.clone(),
done,
})
})
.collect()
}
pub fn update(&self) -> Result<Vec<Outcome>, PlanError> {
let assessment = self.assess_with(&|_| false)?;
Ok(self.apply(&assessment, &Approval::default()))
}
pub fn remove(&self) -> Result<Vec<Outcome>, PlanError> {
let assessment = self.assess_with(&|_| false)?;
Ok(assessment
.steps
.iter()
.filter(|step| {
matches!(
step.found,
Found::Ours {
unmodified: true,
..
}
)
})
.map(|step| Outcome {
place: step.place.clone(),
done: match remove_dir(&step.skill_dir) {
Ok(()) => Done::Removed,
Err(error) => Done::Failed(error.to_string()),
},
})
.collect())
}
fn found(&self, skill_dir: &Path) -> Found {
let meta = match fs::symlink_metadata(skill_dir) {
Ok(meta) => meta,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Found::Absent,
Err(error) => return Found::Foreign(error.to_string()),
};
if meta.file_type().is_symlink() {
return Found::Foreign("is a symlink".to_string());
}
if !meta.is_dir() {
return Found::Foreign("is not a directory".to_string());
}
let Ok(skill_md) = fs::read(skill_dir.join(SKILL_FILE)) else {
return Found::Foreign(format!("has no readable {SKILL_FILE}"));
};
let Some(record) = record::parse(&skill_md) else {
return Found::Foreign("carries no installation record".to_string());
};
if record.installed_by != self.program.name {
return Found::Foreign(format!("was installed by {}", record.installed_by));
}
let Ok(version) = semver::Version::parse(&record.installed_version) else {
return Found::Foreign("has an unreadable version in its record".to_string());
};
let unmodified = record::read_dir(skill_dir)
.is_ok_and(|files| record::digest(&files) == record.content_digest);
Found::Ours {
version,
unmodified,
}
}
fn action(&self, found: &Found, planned: bool, declined: bool) -> Action {
match found {
Found::Absent if !planned => Action::Nothing(Why::NotPlanned),
Found::Absent if declined => Action::Nothing(Why::Declined),
Found::Absent => Action::Install,
Found::Ours {
unmodified: false, ..
} => Action::Report(Problem::Modified),
Found::Ours { version, .. } if *version < self.program.version => Action::Update {
from: version.clone(),
},
Found::Ours { version, .. } if *version > self.program.version => {
Action::Nothing(Why::Newer(version.clone()))
}
Found::Ours { .. } => Action::Nothing(Why::Current),
Found::Foreign(why) => Action::Report(Problem::Foreign(why.clone())),
}
}
fn write(&self, step: &Step, done: Done) -> Done {
let files = record::sealed(
self.skill
.files_with_record(&self.program.name, &self.program.version.to_string()),
);
let mut changed = false;
let result = replace_dir(&step.place, &step.skill_dir, &files, &mut |stage, path| {
if stage == Stage::OldMovedAside && self.found(path) != step.found {
changed = true;
return Err(io::Error::other("changed since the assessment"));
}
Ok(())
});
match result {
Ok(()) => done,
Err(_) if changed => Done::Changed,
Err(error) => Done::Failed(error.to_string()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Staged,
OldMovedAside,
}
const STALE: std::time::Duration = std::time::Duration::from_hours(1);
fn sweep_stale(place: &Path, skill_dir: &Path) {
let (Some(parent), Some(name)) = (place.parent(), skill_dir.file_name()) else {
return;
};
let prefix = format!(".asi-{}-", name.to_string_lossy());
let Ok(entries) = fs::read_dir(parent) else {
return;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(rest) = file_name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
continue;
};
let stamp = rest
.rsplit_once('.')
.and_then(|(head, _)| head.rsplit_once('-'))
.and_then(|(_, stamp)| stamp.parse::<u128>().ok());
if stamp.is_some_and(|stamp| now.saturating_sub(stamp) > STALE.as_nanos()) {
let _ = fs::remove_dir_all(entry.path());
}
}
}
fn staging(place: &Path, skill_dir: &Path, kind: &str) -> io::Result<PathBuf> {
let parent = place.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"a skills directory without a parent",
)
})?;
let name = skill_dir.file_name().unwrap_or_default().to_string_lossy();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
Ok(parent.join(format!(".asi-{name}-{}-{stamp}.{kind}", std::process::id())))
}
pub fn replace_dir(
place: &Path,
skill_dir: &Path,
files: &BTreeMap<PathBuf, Vec<u8>>,
interrupt: &mut dyn FnMut(Stage, &Path) -> io::Result<()>,
) -> io::Result<()> {
sweep_stale(place, skill_dir);
fs::create_dir_all(place)?;
let new = staging(place, skill_dir, "new")?;
let written = (|| {
for (relative, content) in files {
let path = new.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
}
interrupt(Stage::Staged, &new)
})();
if let Err(error) = written {
let _ = fs::remove_dir_all(&new);
return Err(error);
}
let old = staging(place, skill_dir, "old")?;
let had_old = fs::symlink_metadata(skill_dir).is_ok();
if had_old && let Err(error) = fs::rename(skill_dir, &old) {
let _ = fs::remove_dir_all(&new);
return Err(error);
}
let moved = interrupt(Stage::OldMovedAside, &old).and_then(|()| fs::rename(&new, skill_dir));
if let Err(error) = moved {
if had_old {
let _ = fs::rename(&old, skill_dir);
}
let _ = fs::remove_dir_all(&new);
return Err(error);
}
if had_old {
let _ = fs::remove_dir_all(&old);
}
Ok(())
}
fn remove_dir(skill_dir: &Path) -> io::Result<()> {
let place = skill_dir.parent().unwrap_or(skill_dir);
sweep_stale(place, skill_dir);
let aside = staging(place, skill_dir, "gone")?;
fs::rename(skill_dir, &aside)?;
let _ = fs::remove_dir_all(aside);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn files(content: &str) -> BTreeMap<PathBuf, Vec<u8>> {
BTreeMap::from([(PathBuf::from(SKILL_FILE), content.as_bytes().to_vec())])
}
fn leftovers(parent: &Path) -> Vec<String> {
fs::read_dir(parent)
.expect("read")
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter(|name| name.starts_with(".asi-"))
.collect()
}
#[test]
fn an_interrupted_replacement_leaves_the_old_skill_intact() {
for stop_at in [Stage::Staged, Stage::OldMovedAside] {
let home = tempfile::tempdir().expect("home");
let place = home.path().join("tool/skills");
let skill_dir = place.join("devserial");
replace_dir(&place, &skill_dir, &files("old"), &mut |_, _| Ok(())).expect("first");
let result = replace_dir(&place, &skill_dir, &files("new"), &mut |stage, _| {
if stage == stop_at {
Err(io::Error::other("interrupted"))
} else {
Ok(())
}
});
assert!(result.is_err(), "{stop_at:?}");
assert_eq!(
fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("old"),
"old",
"{stop_at:?}"
);
assert_eq!(
leftovers(&home.path().join("tool")),
Vec::<String>::new(),
"{stop_at:?}"
);
assert_eq!(
fs::read_dir(&place).expect("read").count(),
1,
"{stop_at:?}"
);
}
}
#[test]
fn an_uninterrupted_replacement_leaves_only_the_new_skill() {
let home = tempfile::tempdir().expect("home");
let place = home.path().join("tool/skills");
let skill_dir = place.join("devserial");
replace_dir(&place, &skill_dir, &files("old"), &mut |_, _| Ok(())).expect("first");
replace_dir(&place, &skill_dir, &files("new"), &mut |_, _| Ok(())).expect("second");
assert_eq!(
fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("new"),
"new"
);
assert_eq!(leftovers(&home.path().join("tool")), Vec::<String>::new());
}
#[test]
fn stale_staging_is_swept_and_young_staging_kept() {
let home = tempfile::tempdir().expect("home");
let parent = home.path().join("tool");
let place = parent.join("skills");
let skill_dir = place.join("devserial");
fs::create_dir_all(&parent).expect("parent");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let old = now - std::time::Duration::from_hours(2).as_nanos();
let young = now - std::time::Duration::from_mins(1).as_nanos();
let stale = parent.join(format!(".asi-devserial-1-{old}.new"));
let fresh = parent.join(format!(".asi-devserial-1-{young}.new"));
let other = parent.join(format!(".asi-other-1-{old}.new"));
for dir in [&stale, &fresh, &other] {
fs::create_dir_all(dir).expect("staging");
}
replace_dir(&place, &skill_dir, &files("x"), &mut |_, _| Ok(())).expect("replace");
assert!(!stale.exists(), "a stale staging directory was left");
assert!(fresh.exists(), "a young staging directory was swept");
assert!(other.exists(), "another skill's staging was swept");
}
#[cfg(unix)]
#[test]
fn a_failed_cleanup_does_not_fail_the_replacement() {
use std::os::unix::fs::PermissionsExt as _;
let home = tempfile::tempdir().expect("home");
let place = home.path().join("tool/skills");
let skill_dir = place.join("devserial");
let mut first = files("old");
first.insert(PathBuf::from("locked/file.md"), b"x".to_vec());
replace_dir(&place, &skill_dir, &first, &mut |_, _| Ok(())).expect("first");
let locked = skill_dir.join("locked");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).expect("lock");
let result = replace_dir(&place, &skill_dir, &files("new"), &mut |_, _| Ok(()));
let left = leftovers(&home.path().join("tool"));
for dir in &left {
let _ = fs::set_permissions(
home.path().join("tool").join(dir).join("locked"),
fs::Permissions::from_mode(0o755),
);
}
result.expect("the replacement reported a failure");
assert_eq!(
fs::read_to_string(skill_dir.join(SKILL_FILE)).expect("new"),
"new"
);
assert_eq!(
left.len(),
1,
"the old copy should be left for the sweep: {left:?}"
);
}
#[test]
fn staging_is_beside_the_skills_directory() {
let home = tempfile::tempdir().expect("home");
let place = home.path().join("tool/skills");
let skill_dir = place.join("devserial");
let mut seen = Vec::new();
replace_dir(&place, &skill_dir, &files("x"), &mut |_, _| {
seen.extend(
fs::read_dir(&place)
.expect("read")
.filter_map(Result::ok)
.map(|e| e.file_name()),
);
Ok(())
})
.expect("replace");
assert!(
seen.is_empty(),
"the skills directory held {seen:?} while staging"
);
}
}