impl ArtifactWriter {
pub fn new(root: PathBuf) -> Result<Self, TemplateError> {
fs::create_dir_all(&root).map_err(TemplateError::Io)?;
let manifest_path = root.join("artifacts.json");
let manifest = if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path).map_err(TemplateError::Io)?;
serde_json::from_str(&content).map_err(|e| TemplateError::InvalidUtf8(e.to_string()))?
} else {
BTreeMap::new()
};
Ok(Self { root, manifest })
}
pub fn write_artifacts(&mut self, tree: &ArtifactTree) -> Result<(), TemplateError> {
self.create_directory_structure()?;
for (name, content) in &tree.dogfooding {
let artifact_type = if name.ends_with(".md") {
ArtifactType::DogfoodingMarkdown
} else {
ArtifactType::DogfoodingJson
};
let path = self.root.join("dogfooding").join(name);
let hash = self.write_with_hash(&path, content, artifact_type.clone())?;
self.manifest.insert(
name.clone(),
ArtifactMetadata {
path: path.clone(),
hash: format!("{hash}"),
size: content.len(),
generated_at: Utc::now(),
artifact_type,
},
);
}
self.write_mermaid_artifacts(&tree.mermaid)?;
self.write_template_artifacts(&tree.templates)?;
self.write_manifest()?;
Ok(())
}
fn create_directory_structure(&self) -> Result<(), TemplateError> {
let directories = [
"dogfooding",
"mermaid",
"mermaid/ast-generated",
"mermaid/ast-generated/simple",
"mermaid/ast-generated/styled",
"mermaid/non-code",
"mermaid/non-code/simple",
"mermaid/non-code/styled",
"mermaid/fixtures",
"templates",
];
for dir in &directories {
let path = self.root.join(dir);
fs::create_dir_all(&path).map_err(TemplateError::Io)?;
}
Ok(())
}
fn write_mermaid_artifacts(
&mut self,
artifacts: &MermaidArtifacts,
) -> Result<(), TemplateError> {
for (name, content) in &artifacts.ast_generated {
let subdir = if name.contains("styled") {
"styled"
} else {
"simple"
};
let path = self
.root
.join("mermaid")
.join("ast-generated")
.join(subdir)
.join(name);
let hash = self.write_with_hash(&path, content, ArtifactType::MermaidDiagram)?;
self.manifest.insert(
format!("mermaid/ast-generated/{subdir}/{name}"),
ArtifactMetadata {
path: path.clone(),
hash: format!("{hash}"),
size: content.len(),
generated_at: Utc::now(),
artifact_type: ArtifactType::MermaidDiagram,
},
);
}
for (name, content) in &artifacts.non_code {
let subdir = if name.contains("styled") {
"styled"
} else {
"simple"
};
let path = self
.root
.join("mermaid")
.join("non-code")
.join(subdir)
.join(name);
let hash = self.write_with_hash(&path, content, ArtifactType::MermaidDiagram)?;
self.manifest.insert(
format!("mermaid/non-code/{subdir}/{name}"),
ArtifactMetadata {
path: path.clone(),
hash: format!("{hash}"),
size: content.len(),
generated_at: Utc::now(),
artifact_type: ArtifactType::MermaidDiagram,
},
);
}
Ok(())
}
fn write_template_artifacts(&mut self, templates: &[Template]) -> Result<(), TemplateError> {
for template in templates {
let filename = format!("{}.hbs", template.name);
let path = self.root.join("templates").join(&filename);
let hash = self.write_with_hash(&path, &template.content, ArtifactType::Template)?;
self.manifest.insert(
format!("templates/{filename}"),
ArtifactMetadata {
path: path.clone(),
hash: format!("{hash}"),
size: template.content.len(),
generated_at: Utc::now(),
artifact_type: ArtifactType::Template,
},
);
}
Ok(())
}
fn write_with_hash(
&self,
path: &Path,
content: &str,
_artifact_type: ArtifactType,
) -> Result<Hash, TemplateError> {
let hash = blake3::hash(content.as_bytes());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(TemplateError::Io)?;
}
let temp_path = path.with_extension("tmp");
fs::write(&temp_path, content).map_err(TemplateError::Io)?;
fs::rename(temp_path, path).map_err(TemplateError::Io)?;
Ok(hash)
}
fn write_manifest(&mut self) -> Result<(), TemplateError> {
let manifest_path = self.root.join("artifacts.json");
let manifest_content = serde_json::to_string_pretty(&self.manifest)
.map_err(|e| TemplateError::InvalidUtf8(e.to_string()))?;
let hash = blake3::hash(manifest_content.as_bytes());
self.manifest.insert(
"artifacts.json".to_string(),
ArtifactMetadata {
path: manifest_path.clone(),
hash: format!("{hash}"),
size: manifest_content.len(),
generated_at: Utc::now(),
artifact_type: ArtifactType::Manifest,
},
);
let final_content = serde_json::to_string_pretty(&self.manifest)
.map_err(|e| TemplateError::InvalidUtf8(e.to_string()))?;
let temp_path = manifest_path.with_extension("tmp");
{
let file = File::create(&temp_path).map_err(TemplateError::Io)?;
let mut writer = BufWriter::new(file);
writer
.write_all(final_content.as_bytes())
.map_err(TemplateError::Io)?;
writer.flush().map_err(TemplateError::Io)?;
}
fs::rename(temp_path, manifest_path).map_err(TemplateError::Io)?;
Ok(())
}
pub fn verify_integrity(&self) -> Result<VerificationReport, TemplateError> {
let mut report = VerificationReport {
total_artifacts: self.manifest.len(),
verified: 0,
failed: Vec::new(),
missing: Vec::new(),
};
for (name, metadata) in &self.manifest {
if !metadata.path.exists() {
report.missing.push(name.clone());
continue;
}
let content = fs::read_to_string(&metadata.path).map_err(TemplateError::Io)?;
let computed_hash = blake3::hash(content.as_bytes());
if format!("{computed_hash}") == metadata.hash {
report.verified += 1;
} else {
report.failed.push(IntegrityFailure {
artifact: name.clone(),
expected_hash: metadata.hash.clone(),
actual_hash: format!("{computed_hash}"),
});
}
}
Ok(report)
}
#[must_use]
pub fn get_statistics(&self) -> ArtifactStatistics {
let mut stats = ArtifactStatistics {
total_artifacts: self.manifest.len(),
total_size: 0,
by_type: BTreeMap::new(),
oldest: None,
newest: None,
};
for metadata in self.manifest.values() {
stats.total_size += metadata.size;
let type_stats = stats
.by_type
.entry(format!("{:?}", metadata.artifact_type))
.or_insert(TypeStatistics { count: 0, size: 0 });
type_stats.count += 1;
type_stats.size += metadata.size;
if stats.oldest.is_none()
|| stats.oldest.as_ref().expect("internal error") > &metadata.generated_at
{
stats.oldest = Some(metadata.generated_at);
}
if stats.newest.is_none()
|| stats.newest.as_ref().expect("internal error") < &metadata.generated_at
{
stats.newest = Some(metadata.generated_at);
}
}
stats
}
pub fn cleanup_old_artifacts(
&mut self,
max_age_days: u32,
) -> Result<CleanupReport, TemplateError> {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(max_age_days));
let mut removed = Vec::new();
let mut failed = Vec::new();
let old_artifacts: Vec<_> = self
.manifest
.iter()
.filter(|(_, metadata)| metadata.generated_at < cutoff)
.map(|(name, _)| name.clone())
.collect();
for name in old_artifacts {
if let Some(metadata) = self.manifest.remove(&name) {
match fs::remove_file(&metadata.path) {
Ok(()) => removed.push(name),
Err(e) => {
failed.push((name.clone(), e.to_string()));
self.manifest.insert(name, metadata);
}
}
}
}
if !removed.is_empty() {
self.write_manifest()?;
}
Ok(CleanupReport { removed, failed })
}
}