mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
192 lines
7.2 KiB
Rust
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);
|
|
}
|
|
}
|