Files
BrowserSkill/crates/bsk-cli/src/skill_install/sync.rs
T
2026-06-22 17:36:26 +08:00

192 lines
7.2 KiB
Rust

//! Keep installed `SKILL.md` files in sync with the `bsk` binary's
//! bundled copy. Best-effort: I/O errors are recorded, never thrown.
use std::path::Path;
use super::{DEFAULT_SKILL_MD, harness::HarnessId};
/// Per-harness outcome of a sync pass.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncReport {
/// Harnesses whose on-disk `SKILL.md` differed and was rewritten.
pub updated: Vec<HarnessId>,
/// Harnesses whose on-disk `SKILL.md` already matched the bundled
/// content; no write happened, mtime preserved.
pub up_to_date: Vec<HarnessId>,
/// Harnesses that have an installed `SKILL.md` but the sync attempt
/// failed with an I/O error. The string is a human-readable detail.
pub errors: Vec<(HarnessId, String)>,
}
/// Iterates `HarnessId::ALL`, syncing harnesses with an existing
/// `SKILL.md` and leaving the rest untouched.
pub fn sync_installed_skills(home: &Path) -> SyncReport {
sync_with_source(home, DEFAULT_SKILL_MD)
}
/// Test seam: lets unit tests inject a synthetic "bundled" payload.
pub(crate) fn sync_with_source(home: &Path, source: &str) -> SyncReport {
let mut report = SyncReport::default();
for &harness in HarnessId::ALL {
let dest = harness.skill_dest_dir_for_home(home).join("SKILL.md");
match sync_one(&dest, source) {
SyncOne::Missing => continue,
SyncOne::UpToDate => report.up_to_date.push(harness),
SyncOne::Updated => report.updated.push(harness),
SyncOne::Error(msg) => report.errors.push((harness, msg)),
}
}
report
}
enum SyncOne {
Missing,
UpToDate,
Updated,
Error(String),
}
fn sync_one(dest: &Path, source: &str) -> SyncOne {
if !dest.is_file() {
return SyncOne::Missing;
}
let on_disk = match std::fs::read_to_string(dest) {
Ok(s) => s,
Err(err) => return SyncOne::Error(format!("read {}: {err}", dest.display())),
};
if on_disk == source {
return SyncOne::UpToDate;
}
// Atomic replace: write tmp, rename over. Including pid in the
// tmp suffix avoids concurrent processes racing on the same path.
let tmp = dest.with_extension(format!("md.tmp.{}", std::process::id()));
if let Err(err) = std::fs::write(&tmp, source) {
// Best-effort cleanup of any partial tmp left by a half-written attempt.
let _ = std::fs::remove_file(&tmp);
return SyncOne::Error(format!("write {}: {err}", tmp.display()));
}
if let Err(err) = std::fs::rename(&tmp, dest) {
// Best-effort cleanup of the orphan tmp file.
let _ = std::fs::remove_file(&tmp);
return SyncOne::Error(format!(
"rename {} -> {}: {err}",
tmp.display(),
dest.display()
));
}
SyncOne::Updated
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn sync_skips_uninstalled_harness() {
let tmp = TempDir::new().unwrap();
let report = sync_with_source(tmp.path(), "anything");
assert!(report.updated.is_empty());
assert!(report.up_to_date.is_empty());
assert!(report.errors.is_empty());
// Defensive: sync must not silently create files in harnesses that
// never had the skill installed. This guards Task 2's real impl.
let dest = HarnessId::Cursor
.skill_dest_dir_for_home(tmp.path())
.join("SKILL.md");
assert!(
!dest.exists(),
"sync should not create files for uninstalled harnesses"
);
}
#[test]
fn sync_updates_outdated_skill() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let dest_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
std::fs::create_dir_all(&dest_dir).unwrap();
let dest = dest_dir.join("SKILL.md");
std::fs::write(&dest, b"old content").unwrap();
let report = sync_with_source(home, "fresh content");
assert_eq!(report.updated, vec![HarnessId::Cursor]);
assert!(report.up_to_date.is_empty());
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "fresh content");
// Atomicity guard: no orphan tmp files matching SKILL.md.tmp.* should
// remain after a successful sync (regardless of pid suffix).
let leftovers: Vec<_> = std::fs::read_dir(&dest_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name())
.filter(|name| name.to_string_lossy().starts_with("SKILL.md.tmp"))
.collect();
assert!(
leftovers.is_empty(),
"no SKILL.md.tmp.* files should remain after sync, found: {leftovers:?}"
);
}
#[test]
fn sync_skips_when_up_to_date() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let dest_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
std::fs::create_dir_all(&dest_dir).unwrap();
let dest = dest_dir.join("SKILL.md");
std::fs::write(&dest, "frozen content").unwrap();
let mtime_before = std::fs::metadata(&dest).unwrap().modified().unwrap();
// Sleep enough that any rewrite would visibly change mtime on
// platforms with coarse fs timestamps (HFS+ has 1 s granularity).
std::thread::sleep(std::time::Duration::from_millis(1100));
let report = sync_with_source(home, "frozen content");
assert_eq!(report.up_to_date, vec![HarnessId::Cursor]);
assert!(report.updated.is_empty());
assert!(report.errors.is_empty());
let mtime_after = std::fs::metadata(&dest).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"up-to-date sync should not touch mtime"
);
}
#[cfg(unix)]
#[test]
fn sync_continues_on_partial_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let home = tmp.path();
// Cursor: writable, outdated → should be updated.
let cursor_dir = HarnessId::Cursor.skill_dest_dir_for_home(home);
std::fs::create_dir_all(&cursor_dir).unwrap();
std::fs::write(cursor_dir.join("SKILL.md"), "old").unwrap();
// Codex: parent dir set to r-x. Reads still succeed, but creating
// SKILL.md.tmp fails → exercises sync_one's write-tmp error branch.
let codex_dir = HarnessId::Codex.skill_dest_dir_for_home(home);
std::fs::create_dir_all(&codex_dir).unwrap();
std::fs::write(codex_dir.join("SKILL.md"), "old").unwrap();
let mut perms = std::fs::metadata(&codex_dir).unwrap().permissions();
perms.set_mode(0o500); // r-x: blocks tmp creation in this dir
std::fs::set_permissions(&codex_dir, perms).unwrap();
let report = sync_with_source(home, "fresh");
// Restore perms so TempDir can clean up.
let mut perms = std::fs::metadata(&codex_dir).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&codex_dir, perms).unwrap();
assert_eq!(report.updated, vec![HarnessId::Cursor]);
assert_eq!(report.errors.len(), 1);
assert_eq!(report.errors[0].0, HarnessId::Codex);
}
}