use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
const MANIFEST_FILENAME: &str = "init-manifest.json";
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub(super) struct InitManifest {
pub crosslink_version: String,
pub initialized_at: String,
pub files: BTreeMap<String, ManifestEntry>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub(super) struct ManifestEntry {
pub sha256: String,
pub written_by_version: String,
}
#[derive(Debug, PartialEq)]
#[allow(dead_code)] pub(super) enum UpdateAction {
UpToDate,
AutoUpdate,
TemplateUnchanged,
Conflict,
Deleted,
NewFile,
}
pub(super) fn sha256_hex(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
pub(super) fn sha256_file(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(sha256_hex(&content))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("Failed to read {} for hashing", path.display()))),
}
}
pub(super) fn read_manifest(crosslink_dir: &Path) -> Option<InitManifest> {
let path = crosslink_dir.join(MANIFEST_FILENAME);
let raw = fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
}
pub(super) fn write_manifest(crosslink_dir: &Path, manifest: &InitManifest) -> Result<()> {
let path = crosslink_dir.join(MANIFEST_FILENAME);
let tmp_path = crosslink_dir.join(format!("{MANIFEST_FILENAME}.tmp"));
let mut output =
serde_json::to_string_pretty(manifest).context("Failed to serialize init-manifest.json")?;
output.push('\n');
fs::write(&tmp_path, &output).context("Failed to write init-manifest.json.tmp")?;
fs::rename(&tmp_path, &path)
.context("Failed to rename init-manifest.json.tmp → init-manifest.json")?;
Ok(())
}
pub(super) fn build_manifest(files: &[(String, String)]) -> InitManifest {
let version = env!("CARGO_PKG_VERSION").to_string();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut entries = BTreeMap::new();
for (path, content) in files {
entries.insert(
path.clone(),
ManifestEntry {
sha256: sha256_hex(content),
written_by_version: version.clone(),
},
);
}
InitManifest {
crosslink_version: version,
initialized_at: now,
files: entries,
}
}
pub(super) fn classify_update(
manifest_hash: &str,
current_hash: Option<&str>,
new_template_hash: &str,
) -> UpdateAction {
current_hash.map_or(UpdateAction::Deleted, |current| {
let user_changed = manifest_hash != current;
let template_changed = manifest_hash != new_template_hash;
match (user_changed, template_changed) {
(false, false) => UpdateAction::UpToDate,
(false, true) => UpdateAction::AutoUpdate,
(true, false) => UpdateAction::TemplateUnchanged,
(true, true) => UpdateAction::Conflict,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_sha256_hex_deterministic() {
let hash1 = sha256_hex("hello world");
let hash2 = sha256_hex("hello world");
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64); }
#[test]
fn test_sha256_hex_different_inputs() {
let hash1 = sha256_hex("hello");
let hash2 = sha256_hex("world");
assert_ne!(hash1, hash2);
}
#[test]
fn test_sha256_file_exists() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, "hello").unwrap();
let hash = sha256_file(&path).unwrap();
assert_eq!(hash, Some(sha256_hex("hello")));
}
#[test]
fn test_sha256_file_missing() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.txt");
assert_eq!(sha256_file(&path).unwrap(), None);
}
#[test]
fn test_manifest_roundtrip() {
let dir = tempdir().unwrap();
let files = vec![
("a.py".to_string(), "content a".to_string()),
("b.py".to_string(), "content b".to_string()),
];
let manifest = build_manifest(&files);
write_manifest(dir.path(), &manifest).unwrap();
let loaded = read_manifest(dir.path()).unwrap();
assert_eq!(loaded.crosslink_version, manifest.crosslink_version);
assert_eq!(loaded.files.len(), 2);
assert_eq!(loaded.files["a.py"].sha256, manifest.files["a.py"].sha256);
}
#[test]
fn test_manifest_missing_returns_none() {
let dir = tempdir().unwrap();
assert!(read_manifest(dir.path()).is_none());
}
#[test]
fn test_manifest_corrupt_returns_none() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(MANIFEST_FILENAME), "not json {{{").unwrap();
assert!(read_manifest(dir.path()).is_none());
}
#[test]
fn test_manifest_atomic_write() {
let dir = tempdir().unwrap();
let files = vec![("x.py".to_string(), "content".to_string())];
let manifest = build_manifest(&files);
write_manifest(dir.path(), &manifest).unwrap();
assert!(!dir.path().join(format!("{MANIFEST_FILENAME}.tmp")).exists());
assert!(dir.path().join(MANIFEST_FILENAME).exists());
}
#[test]
fn test_classify_up_to_date() {
assert_eq!(
classify_update("abc", Some("abc"), "abc"),
UpdateAction::UpToDate
);
}
#[test]
fn test_classify_auto_update() {
assert_eq!(
classify_update("abc", Some("abc"), "def"),
UpdateAction::AutoUpdate
);
}
#[test]
fn test_classify_template_unchanged() {
assert_eq!(
classify_update("abc", Some("xyz"), "abc"),
UpdateAction::TemplateUnchanged
);
}
#[test]
fn test_classify_conflict() {
assert_eq!(
classify_update("abc", Some("xyz"), "def"),
UpdateAction::Conflict
);
}
#[test]
fn test_classify_deleted() {
assert_eq!(classify_update("abc", None, "def"), UpdateAction::Deleted);
}
}