use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rayon::prelude::*;
use tempfile::{Builder, NamedTempFile};
use crate::paths::strip_path_prefix;
pub const STORE_ROOT: &str = "store/blake3";
#[derive(Debug, Clone)]
pub struct StoredPackage {
pub digest: String,
pub snapshot_root: PathBuf,
}
pub trait SnapshotSource: Sync {
fn digest(&self) -> &str;
fn package_root(&self) -> &Path;
fn package_files(&self) -> Result<Vec<PathBuf>>;
fn read_package_file(&self, path: &Path) -> Result<Vec<u8>>;
}
pub fn snapshot_packages<T: SnapshotSource>(
cache_root: &Path,
packages: &[T],
) -> Result<Vec<StoredPackage>> {
let store_root = cache_root.join(STORE_ROOT);
fs::create_dir_all(&store_root)
.with_context(|| format!("failed to create store root {}", store_root.display()))?;
packages
.par_iter()
.map(|package| {
let snapshot_root = snapshot_package(&store_root, package)?;
Ok(StoredPackage {
digest: package.digest().to_string(),
snapshot_root,
})
})
.collect::<Vec<_>>()
.into_iter()
.collect()
}
pub fn snapshot_path(cache_root: &Path, digest: &str) -> Result<PathBuf> {
Ok(cache_root
.join(STORE_ROOT)
.join(digest_directory_name(digest)?))
}
pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("cannot atomically write {}", path.display()))?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create parent directory {}", parent.display()))?;
let mut temp = NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
temp.write_all(contents)
.with_context(|| format!("failed to write temp file for {}", path.display()))?;
temp.flush()
.with_context(|| format!("failed to flush temp file for {}", path.display()))?;
temp.persist(path)
.map_err(|error| error.error)
.with_context(|| {
format!(
"failed to persist atomically written file to {}",
path.display()
)
})?;
Ok(())
}
fn snapshot_package<T: SnapshotSource>(store_root: &Path, package: &T) -> Result<PathBuf> {
let digest_dir_name = digest_directory_name(package.digest())?;
let digest_dir = store_root.join(digest_dir_name);
let files = package.package_files()?;
if digest_dir.exists() {
if snapshot_is_complete(&digest_dir, package.package_root(), &files)? {
return Ok(digest_dir);
}
match fs::remove_dir_all(&digest_dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| {
format!(
"failed to remove incomplete snapshot {}",
digest_dir.display()
)
});
}
}
}
if digest_dir.exists() {
return Ok(digest_dir);
}
let staging = Builder::new()
.prefix(&format!(".tmp-{}-", digest_dir_name.replace('/', "_")))
.tempdir_in(store_root)
.with_context(|| {
format!(
"failed to create staging dir for snapshot {}",
digest_dir.display()
)
})?;
let staging_root = staging.path().to_path_buf();
for file in files {
let relative = strip_path_prefix(&file, package.package_root()).with_context(|| {
format!("failed to make {} relative to package root", file.display())
})?;
let target = staging_root.join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create snapshot directory {}", parent.display())
})?;
}
let contents = package
.read_package_file(&file)
.with_context(|| format!("failed to read {} for snapshot", file.display()))?;
write_atomic(&target, &contents).with_context(|| {
format!(
"failed to copy {} into snapshot {}",
file.display(),
target.display()
)
})?;
}
if let Some(parent) = digest_dir.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create store parent {}", parent.display()))?;
}
match fs::rename(&staging_root, &digest_dir) {
Ok(()) => {
let _ = staging.keep();
Ok(digest_dir)
}
Err(_) if digest_dir.exists() => Ok(digest_dir),
Err(error) => Err(error).with_context(|| {
format!(
"failed to promote snapshot {} into {}",
staging_root.display(),
digest_dir.display()
)
}),
}
}
fn snapshot_is_complete(
snapshot_root: &Path,
package_root: &Path,
files: &[PathBuf],
) -> Result<bool> {
for file in files {
let relative = file.strip_prefix(package_root).with_context(|| {
format!("failed to make {} relative to package root", file.display())
})?;
if !snapshot_root.join(relative).is_file() {
return Ok(false);
}
}
Ok(true)
}
fn digest_directory_name(digest: &str) -> Result<&str> {
digest
.strip_prefix("blake3:")
.or_else(|| digest.strip_prefix("sha256:"))
.ok_or_else(|| anyhow::anyhow!("unsupported digest format `{digest}`"))
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::TempDir;
use super::*;
use crate::report::Reporter;
use crate::resolver::resolve_project_for_sync;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(path).unwrap();
file.write_all(contents.as_bytes()).unwrap();
}
#[test]
fn snapshots_package_contents_into_the_local_store() {
let temp = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
write_file(
&temp.path().join("skills/review/SKILL.md"),
"---\nname: Review\ndescription: Example.\n---\n# Review\n",
);
let reporter = Reporter::silent();
let resolution = resolve_project_for_sync(temp.path(), cache.path(), &reporter).unwrap();
let stored = snapshot_packages(cache.path(), &resolution.packages).unwrap();
assert_eq!(stored.len(), 1);
assert!(
stored[0]
.snapshot_root
.starts_with(cache.path().join(STORE_ROOT))
);
assert!(!stored[0].snapshot_root.starts_with(temp.path()));
assert!(
stored[0]
.snapshot_root
.join("skills/review/SKILL.md")
.exists()
);
}
#[test]
fn recreates_incomplete_snapshots() {
let temp = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
write_file(
&temp.path().join("skills/review/SKILL.md"),
"---\nname: Review\ndescription: Example.\n---\n# Review\n",
);
write_file(
&temp.path().join("rules/common/coding-style.md"),
"be consistent\n",
);
let reporter = Reporter::silent();
let resolution = resolve_project_for_sync(temp.path(), cache.path(), &reporter).unwrap();
let stored = snapshot_packages(cache.path(), &resolution.packages).unwrap();
let snapshot_root = &stored[0].snapshot_root;
fs::remove_file(snapshot_root.join("rules/common/coding-style.md")).unwrap();
let rebuilt = snapshot_packages(cache.path(), &resolution.packages).unwrap();
assert_eq!(rebuilt[0].snapshot_root, *snapshot_root);
assert!(
rebuilt[0]
.snapshot_root
.join("rules/common/coding-style.md")
.exists()
);
}
#[test]
fn reuses_the_same_snapshot_for_duplicate_package_digests() {
let temp = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
write_file(
&temp.path().join("nodus.toml"),
r#"
[dependencies]
alpha = { path = "vendor/alpha" }
beta = { path = "vendor/beta" }
"#,
);
write_file(
&temp.path().join("vendor/alpha/skills/shared/SKILL.md"),
"---\nname: Shared\ndescription: Example.\n---\n# Shared\n",
);
write_file(
&temp.path().join("vendor/beta/skills/shared/SKILL.md"),
"---\nname: Shared\ndescription: Example.\n---\n# Shared\n",
);
let reporter = Reporter::silent();
let resolution = resolve_project_for_sync(temp.path(), cache.path(), &reporter).unwrap();
let stored = snapshot_packages(cache.path(), &resolution.packages).unwrap();
let mut dependency_digests = resolution
.packages
.iter()
.filter(|package| matches!(package.alias.as_str(), "alpha" | "beta"))
.map(|package| package.digest.clone())
.collect::<Vec<_>>();
dependency_digests.sort();
dependency_digests.dedup();
assert_eq!(dependency_digests.len(), 1);
let dependency_snapshots = stored
.iter()
.filter(|package| package.digest == dependency_digests[0])
.map(|package| package.snapshot_root.clone())
.collect::<Vec<_>>();
assert_eq!(dependency_snapshots.len(), 2);
assert_eq!(dependency_snapshots[0], dependency_snapshots[1]);
assert!(
dependency_snapshots[0]
.join("skills/shared/SKILL.md")
.is_file()
);
}
#[test]
fn digest_directory_name_accepts_blake3_prefix() {
assert_eq!(digest_directory_name("blake3:abc123").unwrap(), "abc123");
}
#[test]
fn digest_directory_name_accepts_legacy_sha256_prefix() {
assert_eq!(digest_directory_name("sha256:abc123").unwrap(), "abc123");
}
#[test]
fn digest_directory_name_rejects_unknown_prefix() {
assert!(digest_directory_name("md5:abc123").is_err());
}
#[test]
fn atomically_writes_files() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("nested/output.txt");
write_atomic(&target, b"hello").unwrap();
assert_eq!(fs::read_to_string(target).unwrap(), "hello");
}
}