use hmd_core::TomlValueObject;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use toml_edit::DocumentMut;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HmdPatch {
pub patch_version: String,
pub target_hash: Option<String>,
pub operations: Vec<PatchOperation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum PatchOperation {
SetMeta {
target: String,
field: String,
value: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, rename = "createdBy", skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
},
ReplaceBody {
target: String,
markdown: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, rename = "createdBy", skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
},
AppendBlock {
target: String,
block: AppendBlock,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, rename = "createdBy", skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AppendBlock {
pub block_type: String,
#[serde(default)]
pub meta: TomlValueObject,
#[serde(default)]
pub markdown: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchError {
pub message: String,
}
impl std::fmt::Display for PatchError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for PatchError {}
pub fn apply_patch(source: &str, patch: &HmdPatch) -> Result<String, PatchError> {
if patch.patch_version != "0.1" {
return Err(error(format!(
"unsupported patch version '{}'",
patch.patch_version
)));
}
if let Some(target_hash) = &patch.target_hash {
let actual = source_hash(source);
if target_hash != &actual {
return Err(error(format!(
"target hash mismatch: expected {target_hash}, got {actual}"
)));
}
}
validate_operations(source, &patch.operations)?;
let mut current = source.to_string();
for operation in &patch.operations {
current = apply_operation(¤t, operation)?;
}
validate_no_duplicate_ids(¤t)?;
Ok(current)
}
fn validate_operations(source: &str, operations: &[PatchOperation]) -> Result<(), PatchError> {
let document = hmd_parse::parse_document(source);
for operation in operations {
match operation {
PatchOperation::SetMeta { target, .. } | PatchOperation::ReplaceBody { target, .. } => {
validate_block_target(&document, target)?;
}
PatchOperation::AppendBlock { target, block, .. } => {
if target != "/document" {
validate_block_target(&document, target)?;
}
validate_appended_block_id(&document, block)?;
}
}
}
Ok(())
}
fn validate_appended_block_id(
document: &hmd_core::HmdDocument,
block: &AppendBlock,
) -> Result<(), PatchError> {
let Some(id) = block.meta.get("id").and_then(Value::as_str) else {
return Ok(());
};
if document.references.ids.contains_key(id)
|| document
.references
.duplicates
.iter()
.any(|record| record.id == id)
{
return Err(error(format!(
"append_block would create duplicate id '{id}'"
)));
}
Ok(())
}
fn validate_no_duplicate_ids(source: &str) -> Result<(), PatchError> {
let document = hmd_parse::parse_document(source);
if let Some(record) = document.references.duplicates.first() {
return Err(error(format!(
"patch result contains duplicate id '{}'",
record.id
)));
}
Ok(())
}
fn validate_block_target(document: &hmd_core::HmdDocument, target: &str) -> Result<(), PatchError> {
let Some(id) = target.strip_prefix("/blocks/") else {
return Err(error(format!(
"patch target '{target}' must use /blocks/<id>"
)));
};
if document
.references
.duplicates
.iter()
.any(|record| record.id == id)
{
return Err(error(format!("patch target '{target}' is ambiguous")));
}
if !document.references.ids.contains_key(id) {
return Err(error(format!("patch target '{target}' was not found")));
}
Ok(())
}
fn apply_operation(source: &str, operation: &PatchOperation) -> Result<String, PatchError> {
match operation {
PatchOperation::SetMeta {
target,
field,
value,
..
} => apply_set_meta(source, target, field, value),
PatchOperation::ReplaceBody {
target, markdown, ..
} => apply_replace_body(source, target, markdown),
PatchOperation::AppendBlock { target, block, .. } => {
apply_append_block(source, target, block)
}
}
}
fn apply_set_meta(
source: &str,
target: &str,
field: &str,
value: &Value,
) -> Result<String, PatchError> {
let span = find_target_span(source, target)?;
let value_source = toml_literal(value)?;
let mut output = String::new();
if let Some(meta) = &span.meta {
let updated = set_meta_field(&source[meta.content_start..meta.content_end], field, value)?;
output.push_str(&source[..meta.content_start]);
output.push_str(&updated);
output.push_str(&source[meta.content_end..]);
} else {
output.push_str(&source[..span.opener_end]);
output.push_str("+++\n");
output.push_str(field);
output.push_str(" = ");
output.push_str(&value_source);
output.push('\n');
output.push_str("+++\n");
output.push_str(&source[span.opener_end..]);
}
Ok(output)
}
fn apply_replace_body(source: &str, target: &str, markdown: &str) -> Result<String, PatchError> {
let span = find_target_span(source, target)?;
let mut output = String::new();
output.push_str(&source[..span.body_start]);
output.push_str(&body_replacement(markdown));
output.push_str(&source[span.close_start..]);
Ok(output)
}
fn apply_append_block(
source: &str,
target: &str,
block: &AppendBlock,
) -> Result<String, PatchError> {
let block_source = block_to_source(block);
let (insert_at, insertion) = if target == "/document" {
let prefix = if source.ends_with('\n') { "\n" } else { "\n\n" };
(source.len(), format!("{prefix}{block_source}"))
} else {
let span = find_target_span(source, target)?;
(span.close_start, format!("\n{block_source}\n"))
};
let mut output = String::new();
output.push_str(&source[..insert_at]);
output.push_str(&insertion);
output.push_str(&source[insert_at..]);
Ok(output)
}
fn find_target_span(source: &str, target: &str) -> Result<BlockSpan, PatchError> {
let id = target
.strip_prefix("/blocks/")
.ok_or_else(|| error(format!("patch target '{target}' must use /blocks/<id>")))?;
let spans = scan_block_spans(source)?;
spans
.into_iter()
.find(|span| span.id.as_deref() == Some(id))
.ok_or_else(|| error(format!("patch target '{target}' was not found")))
}
fn set_meta_field(source: &str, field: &str, value: &Value) -> Result<String, PatchError> {
let value_source = toml_literal(value)?;
let replacement = format!("{field} = {value_source}\n");
let mut output = String::new();
let mut replaced = false;
for line in split_inclusive_or_once(source) {
if is_toml_key_line(line, field) {
output.push_str(&replacement);
replaced = true;
} else {
output.push_str(line);
}
}
if !replaced {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&replacement);
}
output.parse::<DocumentMut>().map_err(|parse_error| {
error(format!(
"set_meta produced invalid TOML metadata for field '{field}': {parse_error}"
))
})?;
Ok(output)
}
fn is_toml_key_line(line: &str, field: &str) -> bool {
let trimmed = line.trim_start_matches([' ', '\t']);
let Some(rest) = trimmed.strip_prefix(field) else {
return false;
};
rest.trim_start_matches([' ', '\t']).starts_with('=')
}
fn body_replacement(markdown: &str) -> String {
let trimmed = markdown.trim_matches('\n');
if trimmed.is_empty() {
"\n".to_string()
} else {
format!("\n{trimmed}\n\n")
}
}
fn block_to_source(block: &AppendBlock) -> String {
let mut output = String::new();
output.push_str(&format!("::: {}\n", block.block_type));
output.push_str("+++\n");
for (key, value) in &block.meta {
output.push_str(key);
output.push_str(" = ");
output.push_str(&toml_literal(value).expect("JSON value serializes to TOML literal"));
output.push('\n');
}
output.push_str("+++\n");
if !block.markdown.trim().is_empty() {
output.push('\n');
output.push_str(block.markdown.trim_matches('\n'));
output.push_str("\n\n");
}
output.push_str(":::\n");
output
}
fn toml_literal(value: &Value) -> Result<String, PatchError> {
match value {
Value::Null => Err(error("null is not a valid TOML metadata value")),
Value::Bool(value) => Ok(value.to_string()),
Value::Number(value) => Ok(value.to_string()),
Value::String(value) => serde_json::to_string(value)
.map_err(|error| self::error(format!("failed to encode TOML string: {error}"))),
Value::Array(values) => {
let values = values
.iter()
.map(toml_literal)
.collect::<Result<Vec<_>, _>>()?;
Ok(format!("[{}]", values.join(", ")))
}
Value::Object(values) => {
let values = values
.iter()
.map(|(key, value)| Ok(format!("{key} = {}", toml_literal(value)?)))
.collect::<Result<Vec<_>, PatchError>>()?;
Ok(format!("{{ {} }}", values.join(", ")))
}
}
}
#[derive(Debug, Clone)]
struct BlockSpan {
id: Option<String>,
opener_end: usize,
body_start: usize,
close_start: usize,
meta: Option<MetaSpan>,
}
#[derive(Debug, Clone)]
struct MetaSpan {
content_start: usize,
content_end: usize,
}
#[derive(Debug, Clone)]
struct OpenBlock {
id: Option<String>,
fence_length: usize,
opener_end: usize,
body_start: usize,
meta: Option<MetaSpan>,
}
fn scan_block_spans(source: &str) -> Result<Vec<BlockSpan>, PatchError> {
let lines = collect_lines(source);
let mut spans = Vec::new();
let mut stack: Vec<OpenBlock> = Vec::new();
let mut index = body_start_index(&lines);
while index < lines.len() {
let line = &lines[index];
if let Some(open) = stack.last() {
if is_closer(line.content, open.fence_length) {
let open = stack.pop().expect("stack was not empty");
spans.push(BlockSpan {
id: open.id,
opener_end: open.opener_end,
body_start: open.body_start,
close_start: line.start,
meta: open.meta,
});
index += 1;
continue;
}
}
if let Some(opener) = parse_opener(line.content) {
let mut open = OpenBlock {
id: None,
fence_length: opener.fence_length,
opener_end: line.end,
body_start: line.end,
meta: None,
};
index += 1;
if lines
.get(index)
.is_some_and(|line| is_plus_delimiter(line.content))
{
let meta_open = &lines[index];
let Some(meta_close_index) =
lines
.iter()
.enumerate()
.skip(index + 1)
.find_map(|(candidate, line)| {
is_plus_delimiter(line.content).then_some(candidate)
})
else {
return Err(error(format!(
"unterminated block metadata at line {}",
meta_open.number
)));
};
let content_start = lines
.get(index + 1)
.map(|line| line.start)
.unwrap_or(meta_open.end);
let content_end = lines[meta_close_index].start;
let meta_source = &source[content_start..content_end];
open.id = parse_meta_id(meta_source);
open.body_start = lines[meta_close_index].end;
open.meta = Some(MetaSpan {
content_start,
content_end,
});
index = meta_close_index + 1;
}
stack.push(open);
continue;
}
index += 1;
}
Ok(spans)
}
fn body_start_index(lines: &[Line<'_>]) -> usize {
if !lines
.first()
.is_some_and(|line| is_plus_delimiter(line.content))
{
return 0;
}
lines
.iter()
.enumerate()
.skip(1)
.find_map(|(index, line)| is_plus_delimiter(line.content).then_some(index + 1))
.unwrap_or(lines.len())
}
fn parse_meta_id(source: &str) -> Option<String> {
source.parse::<DocumentMut>().ok().and_then(|document| {
document
.get("id")
.and_then(|value| value.as_str())
.map(str::to_string)
})
}
#[derive(Debug, Clone, Copy)]
struct Line<'a> {
content: &'a str,
start: usize,
end: usize,
number: usize,
}
fn collect_lines(source: &str) -> Vec<Line<'_>> {
let mut lines = Vec::new();
let mut start = 0;
let mut number = 1;
for raw in source.split_inclusive('\n') {
let end = start + raw.len();
lines.push(Line {
content: strip_line_ending(raw),
start,
end,
number,
});
start = end;
number += 1;
}
if start < source.len() {
let raw = &source[start..];
lines.push(Line {
content: strip_line_ending(raw),
start,
end: source.len(),
number,
});
}
lines
}
fn strip_line_ending(line: &str) -> &str {
let without_lf = line.strip_suffix('\n').unwrap_or(line);
without_lf.strip_suffix('\r').unwrap_or(without_lf)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opener<'a> {
fence_length: usize,
block_type: &'a str,
}
fn parse_opener(content: &str) -> Option<Opener<'_>> {
let trimmed = trim_horizontal(content);
let fence_length = trimmed.bytes().take_while(|byte| *byte == b':').count();
if fence_length < 3 {
return None;
}
let block_type = trim_horizontal(&trimmed[fence_length..]);
if block_type.is_empty() || block_type.bytes().all(|byte| byte == b':') {
return None;
}
Some(Opener {
fence_length,
block_type,
})
}
fn is_closer(content: &str, opener_length: usize) -> bool {
let trimmed = trim_horizontal(content);
let length = trimmed.bytes().take_while(|byte| *byte == b':').count();
length >= opener_length && length == trimmed.len()
}
fn is_plus_delimiter(content: &str) -> bool {
trim_horizontal(content) == "+++"
}
fn trim_horizontal(value: &str) -> &str {
value.trim_matches(|ch| ch == ' ' || ch == '\t')
}
fn split_inclusive_or_once(source: &str) -> Vec<&str> {
if source.is_empty() {
Vec::new()
} else {
source.split_inclusive('\n').collect()
}
}
pub fn source_hash(source: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(source.as_bytes());
format!("sha256-{}", to_hex(&hasher.finalize()))
}
fn to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn error(message: impl Into<String>) -> PatchError {
PatchError {
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn patches_task_status_todo_to_done() {
let source = fs::read_to_string(repo_path("fixtures/valid/todo-basic.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"operations": [{
"op": "set_meta",
"target": "/blocks/T-parser",
"field": "status",
"value": "done"
}]
}))
.unwrap();
let patched = apply_patch(&source, &patch).unwrap();
assert!(patched.contains("status = \"done\""));
assert!(!patched.contains("status = \"todo\""));
}
#[test]
fn appends_decision_choice_block() {
let source = fs::read_to_string(repo_path("fixtures/valid/decision-basic.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"operations": [{
"op": "append_block",
"target": "/blocks/D-runtime",
"block": {
"blockType": "choice",
"meta": {
"id": "CH-runtime",
"option": "rust",
"status": "selected"
},
"markdown": "Selected Rust after reviewing the recommendation."
}
}]
}))
.unwrap();
let patched = apply_patch(&source, &patch).unwrap();
assert!(patched.contains("::: choice"));
assert!(patched.contains("id = \"CH-runtime\""));
assert!(patched.contains("Selected Rust after reviewing the recommendation."));
}
#[test]
fn missing_target_fails_without_editing() {
let source = fs::read_to_string(repo_path("fixtures/valid/todo-basic.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"operations": [{
"op": "replace_body",
"target": "/blocks/nope",
"markdown": "No edit."
}]
}))
.unwrap();
let error = apply_patch(&source, &patch).unwrap_err();
assert!(error.message.contains("was not found"));
}
#[test]
fn duplicate_id_target_fails_safely() {
let source = fs::read_to_string(repo_path("fixtures/invalid/duplicate-id.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"operations": [{
"op": "set_meta",
"target": "/blocks/T-dup",
"field": "status",
"value": "done"
}]
}))
.unwrap();
let error = apply_patch(&source, &patch).unwrap_err();
assert!(error.message.contains("ambiguous"));
}
#[test]
fn stale_hash_fails_before_editing() {
let source = fs::read_to_string(repo_path("fixtures/valid/decision-basic.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"targetHash": "sha256-stale",
"operations": [{
"op": "set_meta",
"target": "/blocks/D-runtime",
"field": "status",
"value": "accepted"
}]
}))
.unwrap();
let error = apply_patch(&source, &patch).unwrap_err();
assert!(error.message.contains("target hash mismatch"));
assert!(source.contains("status = \"recommended\""));
}
#[test]
fn duplicate_id_append_fails_safely() {
let source = fs::read_to_string(repo_path("fixtures/valid/decision-basic.hmd")).unwrap();
let patch = serde_json::from_value(serde_json::json!({
"patchVersion": "0.1",
"operations": [{
"op": "append_block",
"target": "/blocks/D-runtime",
"block": {
"blockType": "choice",
"meta": {
"id": "rust",
"option": "rust",
"status": "selected"
},
"markdown": "Duplicate."
}
}]
}))
.unwrap();
let error = apply_patch(&source, &patch).unwrap_err();
assert!(error.message.contains("duplicate id 'rust'"));
}
fn repo_path(path: impl AsRef<Path>) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(path)
}
}