use crate::models::error::TemplateError;
use crate::services::unified_ast_engine::{ArtifactTree, MermaidArtifacts, Template};
use blake3::Hash;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
pub struct ArtifactWriter {
root: PathBuf,
manifest: BTreeMap<String, ArtifactMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactMetadata {
pub path: PathBuf,
pub hash: String,
pub size: usize,
pub generated_at: DateTime<Utc>,
pub artifact_type: ArtifactType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArtifactType {
DogfoodingMarkdown,
DogfoodingJson,
MermaidDiagram,
Template,
Manifest,
}
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().unwrap() > &metadata.generated_at {
stats.oldest = Some(metadata.generated_at);
}
if stats.newest.is_none() || stats.newest.as_ref().unwrap() < &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 })
}
}
#[derive(Debug, Clone)]
pub struct VerificationReport {
pub total_artifacts: usize,
pub verified: usize,
pub failed: Vec<IntegrityFailure>,
pub missing: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct IntegrityFailure {
pub artifact: String,
pub expected_hash: String,
pub actual_hash: String,
}
#[derive(Debug, Clone)]
pub struct ArtifactStatistics {
pub total_artifacts: usize,
pub total_size: usize,
pub by_type: BTreeMap<String, TypeStatistics>,
pub oldest: Option<DateTime<Utc>>,
pub newest: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct TypeStatistics {
pub count: usize,
pub size: usize,
}
#[derive(Debug, Clone)]
pub struct CleanupReport {
pub removed: Vec<String>,
pub failed: Vec<(String, String)>, }
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use tempfile::TempDir;
#[test]
fn test_artifact_writer_creation() {
let temp_dir = TempDir::new().unwrap();
let writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
assert_eq!(writer.manifest.len(), 0);
assert!(temp_dir.path().exists());
}
#[test]
fn test_directory_structure_creation() {
let temp_dir = TempDir::new().unwrap();
let writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
writer.create_directory_structure().unwrap();
let expected_dirs = [
"dogfooding",
"mermaid/ast-generated/simple",
"mermaid/ast-generated/styled",
"mermaid/non-code/simple",
"mermaid/non-code/styled",
"templates",
];
for dir in &expected_dirs {
let path = temp_dir.path().join(dir);
assert!(path.exists(), "Directory {dir} should exist");
assert!(path.is_dir(), "Path {dir} should be a directory");
}
}
#[test]
fn test_atomic_write_with_hash() {
let temp_dir = TempDir::new().unwrap();
let writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
let content = "Hello, World!";
let file_path = temp_dir.path().join("test.txt");
let hash = writer
.write_with_hash(&file_path, content, ArtifactType::DogfoodingMarkdown)
.unwrap();
assert!(file_path.exists());
let read_content = fs::read_to_string(&file_path).unwrap();
assert_eq!(read_content, content);
let expected_hash = blake3::hash(content.as_bytes());
assert_eq!(hash, expected_hash);
}
#[test]
fn test_artifact_tree_writing() {
let temp_dir = TempDir::new().unwrap();
let mut writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
let mut dogfooding = BTreeMap::new();
dogfooding.insert("test.md".to_string(), "# Test Markdown".to_string());
dogfooding.insert(
"metrics.json".to_string(),
r#"{"test": "data"}"#.to_string(),
);
let mut ast_generated = BTreeMap::new();
ast_generated.insert(
"simple-diagram.mmd".to_string(),
"graph TD\n A --> B".to_string(),
);
let mermaid = MermaidArtifacts {
ast_generated,
non_code: BTreeMap::new(),
};
let templates = vec![Template {
name: "test_template".to_string(),
content: "Hello {{name}}!".to_string(),
hash: blake3::hash(b"Hello {{name}}!"),
source_location: PathBuf::from("test.rs"),
}];
let tree = ArtifactTree {
dogfooding,
mermaid,
templates,
};
writer.write_artifacts(&tree).unwrap();
assert!(temp_dir.path().join("dogfooding/test.md").exists());
assert!(temp_dir.path().join("dogfooding/metrics.json").exists());
assert!(temp_dir
.path()
.join("mermaid/ast-generated/simple/simple-diagram.mmd")
.exists());
assert!(temp_dir.path().join("templates/test_template.hbs").exists());
assert!(temp_dir.path().join("artifacts.json").exists());
assert!(writer.manifest.len() >= 4); }
#[test]
fn test_integrity_verification() {
let temp_dir = TempDir::new().unwrap();
let mut writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
let content = "Test content";
let file_path = temp_dir.path().join("test.txt");
let hash = writer
.write_with_hash(&file_path, content, ArtifactType::DogfoodingMarkdown)
.unwrap();
writer.manifest.insert(
"test.txt".to_string(),
ArtifactMetadata {
path: file_path.clone(),
hash: format!("{hash}"),
size: content.len(),
generated_at: Utc::now(),
artifact_type: ArtifactType::DogfoodingMarkdown,
},
);
let report = writer.verify_integrity().unwrap();
assert_eq!(report.verified, 1);
assert_eq!(report.failed.len(), 0);
assert_eq!(report.missing.len(), 0);
fs::write(&file_path, "Corrupted content").unwrap();
let report = writer.verify_integrity().unwrap();
assert_eq!(report.verified, 0);
assert_eq!(report.failed.len(), 1);
assert_eq!(report.missing.len(), 0);
}
#[test]
fn test_statistics() {
let temp_dir = TempDir::new().unwrap();
let mut writer = ArtifactWriter::new(temp_dir.path().to_path_buf()).unwrap();
writer.manifest.insert(
"test1.md".to_string(),
ArtifactMetadata {
path: temp_dir.path().join("test1.md"),
hash: "hash1".to_string(),
size: 100,
generated_at: Utc::now(),
artifact_type: ArtifactType::DogfoodingMarkdown,
},
);
writer.manifest.insert(
"test2.json".to_string(),
ArtifactMetadata {
path: temp_dir.path().join("test2.json"),
hash: "hash2".to_string(),
size: 200,
generated_at: Utc::now(),
artifact_type: ArtifactType::DogfoodingJson,
},
);
let stats = writer.get_statistics();
assert_eq!(stats.total_artifacts, 2);
assert_eq!(stats.total_size, 300);
assert!(stats.by_type.contains_key("DogfoodingMarkdown"));
assert!(stats.by_type.contains_key("DogfoodingJson"));
assert!(stats.oldest.is_some());
assert!(stats.newest.is_some());
}
}
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}