mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user