use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub(crate) const MANIFEST_NAME: &str = ".grr-skills.json";
pub(crate) const BACKUP_DIR: &str = ".grr-backup";
pub(crate) const INCOMING_SUFFIX: &str = ".grr-incoming";
#[cfg(test)]
thread_local! {
static HASH_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_hash_calls() {
HASH_CALLS.with(|calls| calls.set(0));
}
#[cfg(test)]
pub(crate) fn hash_calls() -> u64 {
HASH_CALLS.with(std::cell::Cell::get)
}
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
#[cfg(test)]
HASH_CALLS.with(|calls| calls.set(calls.get() + 1));
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest.as_slice() {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct FileEntry {
pub base_sha256: String,
pub written_sha256: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Manifest {
pub version: String,
#[serde(default)]
pub files: BTreeMap<String, FileEntry>,
}
impl Manifest {
pub(crate) fn path(target: &Path) -> PathBuf {
target.join(MANIFEST_NAME)
}
pub(crate) fn load(target: &Path) -> Option<Manifest> {
let text = fs::read_to_string(Self::path(target)).ok()?;
serde_json::from_str(&text).ok()
}
pub(crate) fn save(&self, target: &Path) -> Result<()> {
let path = Self::path(target);
let body = serde_json::to_string_pretty(self).context("serializing the skills manifest")?;
write_file(&path, &body)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncMode {
Explicit,
Migrate,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct InstallReport {
pub target: PathBuf,
pub installed: Vec<String>,
pub updated: Vec<String>,
pub merged: Vec<String>,
pub unchanged: Vec<String>,
pub skipped: Vec<Skipped>,
pub conflicts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Skipped {
pub path: String,
pub reason: String,
}
impl InstallReport {
fn wrote_anything(&self) -> bool {
!self.installed.is_empty()
|| !self.updated.is_empty()
|| !self.merged.is_empty()
|| !self.conflicts.is_empty()
}
}
pub(crate) fn sync_into(
target: &Path,
packaged: &[(&str, &str)],
version: &str,
force: bool,
mode: SyncMode,
) -> Result<InstallReport> {
let mut report = InstallReport {
target: target.to_path_buf(),
..InstallReport::default()
};
let loaded = Manifest::load(target);
let mut manifest = loaded.clone().unwrap_or_default();
let mut dirty = false;
for &(rel, contents) in packaged {
if let Err(reason) = super::check_skill_entry(rel, contents) {
report.skipped.push(Skipped {
path: rel.to_string(),
reason,
});
continue;
}
let path = target.join(rel);
let packaged_sha = sha256_hex(contents.as_bytes());
let entry = manifest.files.get(rel).cloned();
let local = match fs::read_to_string(&path) {
Ok(local) => Some(local),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
report.skipped.push(Skipped {
path: rel.to_string(),
reason: format!("cannot read existing file: {err}"),
});
continue;
}
};
let Some(local) = local else {
if mode == SyncMode::Migrate && entry.is_some() {
report.skipped.push(Skipped {
path: rel.to_string(),
reason: "removed locally; rerun `grr skills install` to restore".to_string(),
});
continue;
}
write_file(&path, contents)?;
report.installed.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: packaged_sha.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
continue;
};
if local == contents {
report.unchanged.push(rel.to_string());
if entry
.as_ref()
.is_none_or(|e| e.base_sha256 != packaged_sha || e.written_sha256 != packaged_sha)
{
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: packaged_sha.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
}
continue;
}
if force {
write_file(&path, contents)?;
report.updated.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: packaged_sha.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
continue;
}
let Some(entry) = entry else {
report.skipped.push(Skipped {
path: rel.to_string(),
reason:
"exists with different contents; not installed by grr — pass --force to replace"
.to_string(),
});
continue;
};
let local_sha = sha256_hex(local.as_bytes());
if local_sha == entry.written_sha256 && entry.written_sha256 == entry.base_sha256 {
write_file(&path, contents)?;
report.updated.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: packaged_sha.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
continue;
}
let Some(base) = read_backup(target, rel) else {
report.skipped.push(Skipped {
path: rel.to_string(),
reason: "no recorded base to merge against; pass --force to replace".to_string(),
});
continue;
};
match diffy::merge(&base, &local, contents) {
Ok(merged) if merged == local => {
report.unchanged.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: entry.written_sha256.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
}
Ok(merged) => {
write_file(&path, &merged)?;
report.merged.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: sha256_hex(merged.as_bytes()),
},
);
write_backup(target, rel, contents)?;
dirty = true;
}
Err(_) => {
let incoming = incoming_path(&path);
write_file(&incoming, contents)?;
report.conflicts.push(rel.to_string());
manifest.files.insert(
rel.to_string(),
FileEntry {
base_sha256: packaged_sha.clone(),
written_sha256: entry.written_sha256.clone(),
},
);
write_backup(target, rel, contents)?;
dirty = true;
}
}
}
if dirty || manifest.version != version {
manifest.version = version.to_string();
manifest.save(target)?;
}
Ok(report)
}
pub(crate) fn migrate_dir(dir: &Path, packaged: &[(&str, &str)], version: &str) -> Option<String> {
let text = fs::read_to_string(Manifest::path(dir)).ok()?;
let manifest: Manifest = serde_json::from_str(&text).ok()?;
if manifest.version == version {
return None;
}
let from = manifest.version.clone();
let report = sync_into(dir, packaged, version, false, SyncMode::Migrate).ok()?;
summarize(&report, &from, version)
}
fn summarize(report: &InstallReport, from: &str, to: &str) -> Option<String> {
if !report.wrote_anything() {
return None;
}
let mut parts = Vec::new();
if !report.installed.is_empty() {
parts.push(format!("{} installed", report.installed.len()));
}
if !report.updated.is_empty() {
parts.push(format!("{} updated", report.updated.len()));
}
if !report.merged.is_empty() {
parts.push(format!("{} merged", report.merged.len()));
}
if !report.conflicts.is_empty() {
parts.push(format!(
"{} conflicts (new copy kept as {INCOMING_SUFFIX})",
report.conflicts.len()
));
}
let from = if from.is_empty() { "unknown" } else { from };
Some(format!(
"grr: skills migrated {from} -> {to} ({}); local edits were preserved",
parts.join(", ")
))
}
pub(crate) fn classify(
target: &Path,
rel: &str,
packaged: &str,
manifest: Option<&Manifest>,
) -> &'static str {
match fs::read_to_string(target.join(rel)) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => "missing",
Err(_) => "unreadable",
Ok(local) => {
if local == packaged {
return "current";
}
match manifest.and_then(|m| m.files.get(rel)) {
Some(entry) if sha256_hex(local.as_bytes()) == entry.written_sha256 => "outdated",
Some(_) => "locally-modified",
None => "unmanaged",
}
}
}
}
fn backup_path(target: &Path, rel: &str) -> PathBuf {
target.join(BACKUP_DIR).join(rel)
}
fn read_backup(target: &Path, rel: &str) -> Option<String> {
fs::read_to_string(backup_path(target, rel)).ok()
}
fn write_backup(target: &Path, rel: &str, contents: &str) -> Result<()> {
write_file(&backup_path(target, rel), contents)
}
fn incoming_path(path: &Path) -> PathBuf {
let mut raw = path.as_os_str().to_owned();
raw.push(INCOMING_SUFFIX);
PathBuf::from(raw)
}
fn write_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn packaged(body: &'static str) -> [(&'static str, &'static str); 1] {
[("grr/body.md", body)]
}
fn read(target: &Path, rel: &str) -> String {
fs::read_to_string(target.join(rel)).unwrap()
}
#[test]
fn unchanged_install_is_idempotent_and_records_the_manifest() {
let dir = tempdir().unwrap();
let first = sync_into(
dir.path(),
&packaged("hello\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(first.installed, vec!["grr/body.md".to_string()]);
assert!(first.updated.is_empty() && first.merged.is_empty() && first.conflicts.is_empty());
let manifest = Manifest::load(dir.path()).unwrap();
assert_eq!(manifest.version, "1.0.0");
assert_eq!(
manifest.files["grr/body.md"].base_sha256,
sha256_hex(b"hello\n")
);
assert_eq!(
manifest.files["grr/body.md"].written_sha256,
sha256_hex(b"hello\n")
);
assert_eq!(read(dir.path(), ".grr-backup/grr/body.md"), "hello\n");
let second = sync_into(
dir.path(),
&packaged("hello\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(second.unchanged, vec!["grr/body.md".to_string()]);
assert!(second.installed.is_empty() && second.updated.is_empty());
}
#[test]
fn new_version_safe_auto_updates_an_untouched_file() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
let report = sync_into(
dir.path(),
&packaged("v2\n"),
"2.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(report.updated, vec!["grr/body.md".to_string()]);
assert_eq!(read(dir.path(), "grr/body.md"), "v2\n");
assert_eq!(Manifest::load(dir.path()).unwrap().version, "2.0.0");
}
#[test]
fn clean_merge_combines_local_edits_with_the_new_packaged_content() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("alpha\nbravo\ncharlie\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
fs::write(dir.path().join("grr/body.md"), "ALPHA\nbravo\ncharlie\n").unwrap();
let report = sync_into(
dir.path(),
&packaged("alpha\nbravo\nCHARLIE\n"),
"2.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(report.merged, vec!["grr/body.md".to_string()]);
let merged = read(dir.path(), "grr/body.md");
assert!(merged.contains("ALPHA"), "local edit lost: {merged}");
assert!(merged.contains("CHARLIE"), "packaged edit lost: {merged}");
assert!(report.conflicts.is_empty());
let manifest = Manifest::load(dir.path()).unwrap();
assert_eq!(
manifest.files["grr/body.md"].written_sha256,
sha256_hex(merged.as_bytes())
);
assert_eq!(
manifest.files["grr/body.md"].base_sha256,
sha256_hex(b"alpha\nbravo\nCHARLIE\n")
);
}
#[test]
fn conflicting_edit_leaves_the_local_file_and_drops_an_incoming_copy() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("alpha\nbravo\ncharlie\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
fs::write(dir.path().join("grr/body.md"), "LOCAL\nbravo\ncharlie\n").unwrap();
let report = sync_into(
dir.path(),
&packaged("PACKAGED\nbravo\ncharlie\n"),
"2.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(report.conflicts, vec!["grr/body.md".to_string()]);
assert!(report.merged.is_empty() && report.updated.is_empty());
assert_eq!(read(dir.path(), "grr/body.md"), "LOCAL\nbravo\ncharlie\n");
assert_eq!(
read(dir.path(), "grr/body.md.grr-incoming"),
"PACKAGED\nbravo\ncharlie\n"
);
}
#[test]
fn force_still_overwrites_a_local_edit() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
fs::write(dir.path().join("grr/body.md"), "local\n").unwrap();
let report = sync_into(
dir.path(),
&packaged("v2\n"),
"2.0.0",
true,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(report.updated, vec!["grr/body.md".to_string()]);
assert_eq!(read(dir.path(), "grr/body.md"), "v2\n");
}
#[test]
fn files_without_provenance_are_never_touched() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("grr")).unwrap();
fs::write(dir.path().join("grr/body.md"), "not grr's file\n").unwrap();
let report = sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
assert_eq!(report.skipped.len(), 1);
assert_eq!(read(dir.path(), "grr/body.md"), "not grr's file\n");
}
#[test]
fn auto_migration_runs_only_when_the_recorded_version_differs() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
reset_hash_calls();
assert_eq!(migrate_dir(dir.path(), &packaged("v2\n"), "1.0.0"), None);
assert_eq!(hash_calls(), 0, "same-version fast path must not hash");
assert_eq!(read(dir.path(), "grr/body.md"), "v1\n");
let summary = migrate_dir(dir.path(), &packaged("v2\n"), "2.0.0").unwrap();
assert!(summary.contains("1.0.0 -> 2.0.0"), "{summary}");
assert!(summary.contains("1 updated"), "{summary}");
assert_eq!(read(dir.path(), "grr/body.md"), "v2\n");
}
#[test]
fn auto_migration_never_resurrects_a_locally_deleted_file() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
fs::remove_file(dir.path().join("grr/body.md")).unwrap();
assert_eq!(migrate_dir(dir.path(), &packaged("v2\n"), "2.0.0"), None);
assert!(!dir.path().join("grr/body.md").exists());
}
#[test]
fn missing_manifest_means_nothing_to_migrate() {
let dir = tempdir().unwrap();
assert_eq!(migrate_dir(dir.path(), &packaged("v1\n"), "2.0.0"), None);
}
#[test]
fn classify_separates_outdated_from_locally_modified() {
let dir = tempdir().unwrap();
sync_into(
dir.path(),
&packaged("v1\n"),
"1.0.0",
false,
SyncMode::Explicit,
)
.unwrap();
let manifest = Manifest::load(dir.path());
assert_eq!(
classify(dir.path(), "grr/body.md", "v2\n", manifest.as_ref()),
"outdated"
);
fs::write(dir.path().join("grr/body.md"), "mine\n").unwrap();
assert_eq!(
classify(dir.path(), "grr/body.md", "v2\n", manifest.as_ref()),
"locally-modified"
);
assert_eq!(
classify(dir.path(), "grr/body.md", "mine\n", manifest.as_ref()),
"current"
);
assert_eq!(
classify(dir.path(), "grr/nope.md", "v2\n", manifest.as_ref()),
"missing"
);
}
}