use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::changes::{ArtifactLayout, artifact_layout};
use crate::provenance::content_hash;
use crate::schemata::WorkflowSchema;
#[derive(Debug, Error)]
pub enum RenderError {
#[error("IO failure at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl RenderError {
fn io(path: &Path, source: std::io::Error) -> Self {
RenderError::Io {
path: path.to_path_buf(),
source,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedDocument {
pub artifact_id: String,
pub tree_path: String,
pub target_path: Option<String>,
pub source_note: String,
pub content: String,
}
pub fn render_documents(
change_directory: &Path,
change_folder: &str,
schema: &WorkflowSchema,
) -> Result<Vec<RenderedDocument>, RenderError> {
let mut documents = Vec::new();
for artifact in &schema.artifacts {
match artifact_layout(artifact) {
ArtifactLayout::Note(stem) => {
let file = change_directory.join(format!("{stem}.md"));
if !file.is_file() {
continue;
}
let content = std::fs::read_to_string(&file)
.map_err(|error| RenderError::io(&file, error))?;
let tree_path = artifact.generates.clone();
let target_path = artifact
.target
.as_deref()
.map(|target| join_logical(target, &tree_path));
documents.push(RenderedDocument {
artifact_id: artifact.id.clone(),
target_path,
source_note: format!("{change_folder}/{stem}.md"),
tree_path,
content,
});
}
ArtifactLayout::Folder(name) => {
let directory = change_directory.join(&name);
if !directory.is_dir() {
continue;
}
for relative in walk_markdown(&directory)? {
let file = directory.join(&relative);
let content = std::fs::read_to_string(&file)
.map_err(|error| RenderError::io(&file, error))?;
let tree_path = join_logical(&name, &relative);
let target_path = artifact
.target
.as_deref()
.map(|target| join_logical(target, &relative));
documents.push(RenderedDocument {
artifact_id: artifact.id.clone(),
target_path,
source_note: format!("{change_folder}/{tree_path}"),
tree_path,
content,
});
}
}
}
}
Ok(documents)
}
pub fn aggregate_content_hash(documents: &[RenderedDocument]) -> String {
let mut pairs: Vec<(&str, String)> = documents
.iter()
.map(|document| (document.tree_path.as_str(), content_hash(&document.content)))
.collect();
pairs.sort();
let mut digest = Sha256::new();
for (tree_path, body_hash) in pairs {
digest.update(tree_path.as_bytes());
digest.update([0u8]);
digest.update(body_hash.as_bytes());
digest.update(*b"\n");
}
format!("{:x}", digest.finalize())
}
pub fn write_tree(documents: &[RenderedDocument], destination: &Path) -> Result<(), RenderError> {
if destination.exists() {
std::fs::remove_dir_all(destination)
.map_err(|error| RenderError::io(destination, error))?;
}
std::fs::create_dir_all(destination).map_err(|error| RenderError::io(destination, error))?;
for document in documents {
let path = destination.join(Path::new(&document.tree_path));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| RenderError::io(parent, error))?;
}
std::fs::write(&path, &document.content).map_err(|error| RenderError::io(&path, error))?;
}
Ok(())
}
pub fn review_diff(
documents: &[RenderedDocument],
project_root: &Path,
) -> Result<String, RenderError> {
let mut output = String::new();
for document in documents {
let Some(target_path) = &document.target_path else {
continue;
};
let absolute = project_root.join(Path::new(target_path));
let current = if absolute.is_file() {
let raw = std::fs::read_to_string(&absolute)
.map_err(|error| RenderError::io(&absolute, error))?;
let (_, body) = crate::provenance::split_document(&raw);
Some(body.to_string())
} else {
None
};
if current.as_deref() == Some(document.content.as_str()) {
continue;
}
output.push_str(&format!("diff --git a/{target_path} b/{target_path}\n"));
let (old_content, old_header) = match ¤t {
Some(content) => (content.as_str(), format!("a/{target_path}")),
None => {
output.push_str("new file mode 100644\n");
("", "/dev/null".to_string())
}
};
let text_diff = similar::TextDiff::from_lines(old_content, document.content.as_str());
output.push_str(&format!(
"{}",
text_diff
.unified_diff()
.header(&old_header, &format!("b/{target_path}"))
));
}
Ok(output)
}
fn walk_markdown(directory: &Path) -> Result<Vec<String>, RenderError> {
let mut files = Vec::new();
collect_markdown(directory, "", &mut files)?;
files.sort();
Ok(files)
}
fn collect_markdown(
directory: &Path,
prefix: &str,
files: &mut Vec<String>,
) -> Result<(), RenderError> {
let entries =
std::fs::read_dir(directory).map_err(|error| RenderError::io(directory, error))?;
for entry in entries {
let entry = entry.map_err(|error| RenderError::io(directory, error))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') {
continue;
}
let path = entry.path();
let relative = if prefix.is_empty() {
name.clone().into_owned()
} else {
format!("{prefix}/{name}")
};
if path.is_dir() {
collect_markdown(&path, &relative, files)?;
} else if name.ends_with(".md") && !name.ends_with(".todo.md") {
files.push(relative);
}
}
Ok(())
}
fn join_logical(parent: &str, child: &str) -> String {
if parent.is_empty() {
child.to_string()
} else if child.is_empty() {
parent.to_string()
} else {
format!("{parent}/{child}")
}
}