use super::ExportReport;
use crate::okf::vault_config::CONFIG_DIR;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const MANIFEST_FILE: &str = "export-manifest.json";
pub const MANIFEST_VERSION: i64 = 1;
pub(super) struct Writer<'a> {
dir: &'a Path,
force: bool,
previous: BTreeMap<String, String>,
current: BTreeMap<String, String>,
pub(super) report: ExportReport,
}
impl<'a> Writer<'a> {
pub(super) fn open(dir: &'a Path, force: bool) -> Result<Self, String> {
let path = manifest_path(dir);
let previous = if path.is_file() {
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
parse(&text).map_err(|e| format!("{}: {e}", path.display()))?
} else {
BTreeMap::new()
};
Ok(Writer {
dir,
force,
previous,
current: BTreeMap::new(),
report: ExportReport::default(),
})
}
pub(super) fn put(&mut self, rel: &str, bytes: &[u8]) -> Result<(), String> {
self.put_at(rel, bytes, None)
}
pub(super) fn put_at(
&mut self,
rel: &str,
bytes: &[u8],
modified: Option<SystemTime>,
) -> Result<(), String> {
let digest = hex_digest(bytes);
let path = self.dir.join(rel);
let on_disk = match std::fs::read(&path) {
Ok(found) => Some(hex_digest(&found)),
Err(_) => None,
};
match (on_disk, self.previous.get(rel)) {
(None, _) => self.write(rel, &path, bytes, digest, modified),
(Some(found), Some(recorded)) if &found == recorded && found == digest => {
self.report.files_unchanged += 1;
self.current.insert(rel.to_string(), digest);
Ok(())
}
(Some(found), Some(recorded)) if &found == recorded => {
self.write(rel, &path, bytes, digest, modified)
}
(Some(_), owned) => {
if self.force {
return self.write(rel, &path, bytes, digest, modified);
}
self.report.files_refused += 1;
self.report.refusals.push(match owned {
Some(_) => {
format!("{rel}: edited since the last export (use force to replace)")
}
None => format!("{rel}: not written by an export (use force to replace)"),
});
if let Some(recorded) = owned.cloned() {
self.current.insert(rel.to_string(), recorded);
}
Ok(())
}
}
}
fn write(
&mut self,
rel: &str,
path: &Path,
bytes: &[u8],
digest: String,
modified: Option<SystemTime>,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("creating {}: {e}", parent.display()))?;
}
std::fs::write(path, bytes).map_err(|e| format!("writing {}: {e}", path.display()))?;
if let Some(when) = modified {
stamp(path, when)?;
}
self.report.files_written += 1;
self.current.insert(rel.to_string(), digest);
Ok(())
}
pub(super) fn finish(mut self) -> Result<ExportReport, String> {
let stale: Vec<(String, String)> = self
.previous
.iter()
.filter(|(rel, _)| !self.current.contains_key(*rel))
.map(|(rel, digest)| (rel.clone(), digest.clone()))
.collect();
for (rel, recorded) in stale {
let path = self.dir.join(&rel);
let Ok(found) = std::fs::read(&path) else {
continue;
};
if hex_digest(&found) == recorded || self.force {
std::fs::remove_file(&path)
.map_err(|e| format!("removing {}: {e}", path.display()))?;
self.report.files_deleted += 1;
} else {
self.report.files_refused += 1;
self.report
.refusals
.push(format!("{rel}: edited since the last export, not deleted"));
self.current.insert(rel, recorded);
}
}
self.report.refusals.sort();
let path = manifest_path(self.dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("creating {}: {e}", parent.display()))?;
}
std::fs::write(&path, render(&self.current))
.map_err(|e| format!("writing {}: {e}", path.display()))?;
Ok(self.report)
}
}
pub(super) fn manifest_path(dir: &Path) -> PathBuf {
dir.join(CONFIG_DIR).join(MANIFEST_FILE)
}
fn stamp(path: &Path, when: SystemTime) -> Result<(), String> {
std::fs::File::options()
.write(true)
.open(path)
.and_then(|file| file.set_modified(when))
.map_err(|e| format!("setting the modification time of {}: {e}", path.display()))
}
fn hex_digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn render(files: &BTreeMap<String, String>) -> String {
let document = serde_json::json!({
"kglite_vault": MANIFEST_VERSION,
"files": files,
});
format!(
"{}\n",
serde_json::to_string_pretty(&document).unwrap_or_default()
)
}
fn parse(text: &str) -> Result<BTreeMap<String, String>, String> {
let document: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
match document.get("kglite_vault").and_then(|v| v.as_i64()) {
Some(MANIFEST_VERSION) => {}
Some(other) => {
return Err(format!(
"`kglite_vault: {other}` is not a manifest version this build writes \
({MANIFEST_VERSION})"
))
}
None => return Err("no `kglite_vault` version".to_string()),
}
let Some(files) = document.get("files").and_then(|v| v.as_object()) else {
return Err("no `files` object".to_string());
};
files
.iter()
.map(|(path, digest)| match digest.as_str() {
Some(text) => Ok((path.clone(), text.to_string())),
None => Err(format!("`files.{path}` is not a hash string")),
})
.collect()
}
#[cfg(test)]
#[path = "manifest_tests.rs"]
mod manifest_tests;