use std::path::Path;
use serde_yaml::Value as YamlValue;
use crate::error::{VaultError, VaultResult};
use crate::models::{PatchOperation, PatchRequest, PatchTargetType};
const HEADING_DELIMITER: &str = "::";
pub fn apply_patch(content: &str, request: &PatchRequest, path: &Path) -> VaultResult<String> {
match request.target_type {
PatchTargetType::Heading => patch_heading(content, request, path),
PatchTargetType::Block => patch_block(content, request, path),
PatchTargetType::Frontmatter => patch_frontmatter(content, request, path),
}
}
struct ParsedHeading<'a> {
level: u8,
text: &'a str,
line_idx: usize,
}
fn line_byte_offsets(content: &str) -> Vec<usize> {
let mut offsets = vec![0usize];
for (i, b) in content.bytes().enumerate() {
if b == b'\n' {
offsets.push(i + 1);
}
}
offsets
}
fn line_offset(offsets: &[usize], n: usize, content_len: usize) -> usize {
offsets.get(n).copied().unwrap_or(content_len)
}
fn for_each_non_code_line<'a>(content: &'a str, mut f: impl FnMut(usize, &'a str)) {
let mut in_code_block = false;
let mut fence_char: u8 = b'`';
let mut fence_len: usize = 0;
let mut in_frontmatter = false;
let mut past_frontmatter = false;
for (idx, line) in content.lines().enumerate() {
if !past_frontmatter {
if idx == 0 && line.trim() == "---" {
in_frontmatter = true;
continue;
}
if in_frontmatter {
if line.trim() == "---" {
past_frontmatter = true;
}
continue;
}
past_frontmatter = true;
}
let stripped = line.trim();
if in_code_block {
if !stripped.is_empty()
&& stripped.len() >= fence_len
&& stripped.bytes().all(|b| b == fence_char)
{
in_code_block = false;
}
continue;
}
let left = line.trim_start();
if left.starts_with("```") || left.starts_with("~~~") {
fence_char = left.as_bytes()[0];
fence_len = left.bytes().take_while(|&b| b == fence_char).count();
in_code_block = true;
continue;
}
f(idx, line);
}
}
fn parse_heading(line: &str) -> Option<(u8, &str)> {
let t = line.trim_start();
if !t.starts_with('#') {
return None;
}
let level = t.bytes().take_while(|&b| b == b'#').count();
if level > 6 {
return None;
}
let rest = &t[level..];
if !rest.is_empty() && !rest.starts_with(' ') {
return None;
}
Some((level as u8, rest.trim()))
}
fn find_headings(content: &str) -> Vec<ParsedHeading<'_>> {
let mut out = Vec::new();
for_each_non_code_line(content, |idx, line| {
if let Some((level, text)) = parse_heading(line) {
out.push(ParsedHeading {
level,
text,
line_idx: idx,
});
}
});
out
}
fn ensure_trailing_newline(s: &mut String) {
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
}
fn resolve_heading_range(
headings: &[ParsedHeading<'_>],
target: &str,
total_lines: usize,
path: &Path,
) -> VaultResult<(usize, usize)> {
let segments: Vec<&str> = target.split(HEADING_DELIMITER).collect();
let mut search_start: usize = 0;
let mut search_end: usize = total_lines;
let mut heading_line: usize = 0;
for segment in &segments {
let seg = segment.trim();
let found = headings.iter().find(|h| {
h.line_idx >= search_start
&& h.line_idx < search_end
&& h.text.eq_ignore_ascii_case(seg)
});
match found {
Some(h) => {
heading_line = h.line_idx;
let section_end = headings
.iter()
.find(|n| n.line_idx > h.line_idx && n.level <= h.level)
.map(|n| n.line_idx)
.unwrap_or(total_lines)
.min(search_end);
search_start = h.line_idx + 1;
search_end = section_end;
}
None => {
return Err(VaultError::PatchTargetNotFound {
path: path.to_path_buf(),
target_type: "heading".into(),
target: target.into(),
});
}
}
}
Ok((heading_line, search_end))
}
fn patch_heading(content: &str, request: &PatchRequest, path: &Path) -> VaultResult<String> {
let line_count = content.lines().count();
let headings = find_headings(content);
let (heading_line, section_end) =
resolve_heading_range(&headings, &request.target, line_count, path)?;
let offsets = line_byte_offsets(content);
let after_heading = line_offset(&offsets, heading_line + 1, content.len());
let section_end_byte = line_offset(&offsets, section_end, content.len());
let mut result = String::with_capacity(content.len() + request.content.len() + 2);
match request.operation {
PatchOperation::Prepend => {
result.push_str(&content[..after_heading]);
ensure_trailing_newline(&mut result);
result.push_str(&request.content);
ensure_trailing_newline(&mut result);
result.push_str(&content[after_heading..]);
}
PatchOperation::Append => {
result.push_str(&content[..section_end_byte]);
ensure_trailing_newline(&mut result);
result.push_str(&request.content);
ensure_trailing_newline(&mut result);
result.push_str(&content[section_end_byte..]);
}
PatchOperation::Replace => {
result.push_str(&content[..after_heading]);
ensure_trailing_newline(&mut result);
result.push_str(&request.content);
if section_end_byte < content.len() {
ensure_trailing_newline(&mut result);
}
result.push_str(&content[section_end_byte..]);
}
}
Ok(result)
}
fn find_block_ref_line(content: &str, block_id: &str) -> Option<usize> {
let suffix = format!("^{block_id}");
let mut found = None;
for_each_non_code_line(content, |idx, line| {
if found.is_some() {
return;
}
let trimmed = line.trim_end();
if trimmed.ends_with(&suffix) {
let before = &trimmed[..trimmed.len() - suffix.len()];
if before.is_empty() || before.ends_with(' ') {
found = Some(idx);
}
}
});
found
}
fn patch_block(content: &str, request: &PatchRequest, path: &Path) -> VaultResult<String> {
let block_id = request.target.strip_prefix('^').unwrap_or(&request.target);
let block_line =
find_block_ref_line(content, block_id).ok_or_else(|| VaultError::PatchTargetNotFound {
path: path.to_path_buf(),
target_type: "block".into(),
target: request.target.clone(),
})?;
let offsets = line_byte_offsets(content);
let line_start = offsets[block_line];
let line_end = line_offset(&offsets, block_line + 1, content.len());
let mut result = String::with_capacity(content.len() + request.content.len() + 2);
match request.operation {
PatchOperation::Prepend => {
result.push_str(&content[..line_start]);
result.push_str(&request.content);
ensure_trailing_newline(&mut result);
result.push_str(&content[line_start..]);
}
PatchOperation::Append => {
result.push_str(&content[..line_end]);
result.push_str(&request.content);
ensure_trailing_newline(&mut result);
result.push_str(&content[line_end..]);
}
PatchOperation::Replace => {
let line_text = &content[line_start..line_end];
let trimmed = line_text.trim_end_matches(['\r', '\n']);
let ref_marker = format!("^{block_id}");
let ref_pos =
trimmed
.rfind(&ref_marker)
.ok_or_else(|| VaultError::PatchTargetNotFound {
path: path.to_path_buf(),
target_type: "block".into(),
target: request.target.clone(),
})?;
let block_ref_part = &trimmed[ref_pos..];
let line_ending = &content[line_start + trimmed.len()..line_end];
result.push_str(&content[..line_start]);
if !request.content.is_empty() {
result.push_str(&request.content);
result.push(' ');
}
result.push_str(block_ref_part);
result.push_str(line_ending);
result.push_str(&content[line_end..]);
}
}
Ok(result)
}
struct FrontmatterRange {
yaml_start: usize,
yaml_end: usize,
body_start: usize,
}
fn frontmatter_boundaries(content: &str) -> Option<FrontmatterRange> {
if !content.starts_with("---") {
return None;
}
let first_nl = content.find('\n')?;
if content[..first_nl].trim_end_matches('\r').trim() != "---" {
return None;
}
let yaml_start = first_nl + 1;
let mut pos = yaml_start;
loop {
if pos >= content.len() {
return None;
}
let next_nl = content[pos..].find('\n');
let line_end = next_nl.map(|i| pos + i).unwrap_or(content.len());
let line = content[pos..line_end].trim_end_matches('\r');
if line.trim() == "---" {
let body_start = if line_end < content.len() {
line_end + 1
} else {
content.len()
};
return Some(FrontmatterRange {
yaml_start,
yaml_end: pos,
body_start,
});
}
match next_nl {
Some(_) => pos = line_end + 1,
None => return None,
}
}
}
fn parse_yaml_value(s: &str) -> YamlValue {
serde_yaml::from_str(s).unwrap_or_else(|_| YamlValue::String(s.to_string()))
}
fn yaml_scalar_to_string(v: &YamlValue) -> String {
match v {
YamlValue::String(s) => s.clone(),
YamlValue::Bool(b) => b.to_string(),
YamlValue::Number(n) => n.to_string(),
YamlValue::Null => String::new(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
}
}
fn patch_frontmatter(content: &str, request: &PatchRequest, path: &Path) -> VaultResult<String> {
let range = frontmatter_boundaries(content).ok_or_else(|| VaultError::PatchTargetNotFound {
path: path.to_path_buf(),
target_type: "frontmatter".into(),
target: request.target.clone(),
})?;
let yaml_str = &content[range.yaml_start..range.yaml_end];
let mut mapping: serde_yaml::Mapping = if yaml_str.trim().is_empty() {
serde_yaml::Mapping::new()
} else {
serde_yaml::from_str(yaml_str).map_err(|e| VaultError::FrontmatterParse {
path: path.to_path_buf(),
source: e,
})?
};
let key = YamlValue::String(request.target.clone());
match request.operation {
PatchOperation::Replace => {
mapping.insert(key, parse_yaml_value(&request.content));
}
PatchOperation::Append => {
let current = mapping.remove(&key);
let updated = match current {
Some(YamlValue::Sequence(mut seq)) => {
seq.push(parse_yaml_value(&request.content));
YamlValue::Sequence(seq)
}
Some(YamlValue::String(mut s)) => {
s.push_str(&request.content);
YamlValue::String(s)
}
Some(other) => {
let mut s = yaml_scalar_to_string(&other);
s.push_str(&request.content);
YamlValue::String(s)
}
None => YamlValue::String(request.content.clone()),
};
mapping.insert(key, updated);
}
PatchOperation::Prepend => {
let current = mapping.remove(&key);
let updated = match current {
Some(YamlValue::Sequence(mut seq)) => {
seq.insert(0, parse_yaml_value(&request.content));
YamlValue::Sequence(seq)
}
Some(YamlValue::String(s)) => {
let mut new = request.content.clone();
new.push_str(&s);
YamlValue::String(new)
}
Some(other) => {
let mut new = request.content.clone();
new.push_str(&yaml_scalar_to_string(&other));
YamlValue::String(new)
}
None => YamlValue::String(request.content.clone()),
};
mapping.insert(key, updated);
}
}
let new_yaml = serde_yaml::to_string(&mapping).map_err(|e| VaultError::Other(e.to_string()))?;
let new_yaml = new_yaml
.strip_prefix("---\n")
.or_else(|| new_yaml.strip_prefix("---\r\n"))
.unwrap_or(&new_yaml);
let body = &content[range.body_start..];
let mut result = String::with_capacity(8 + new_yaml.len() + body.len());
result.push_str("---\n");
result.push_str(new_yaml);
if !new_yaml.is_empty() && !new_yaml.ends_with('\n') {
result.push('\n');
}
result.push_str("---\n");
result.push_str(body);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn req(op: PatchOperation, tt: PatchTargetType, target: &str, content: &str) -> PatchRequest {
PatchRequest {
operation: op,
target_type: tt,
target: target.into(),
content: content.into(),
}
}
fn p() -> &'static Path {
Path::new("test.md")
}
const SAMPLE: &str = "\
---
tags:
- rust
- mcp
title: Test Note
---
# Introduction
This is the intro.
## Details
Some details here.
Important detail ^block1
### Sub Details
Sub detail content ^block2
## Summary
Summary text.
# Conclusion
Final thoughts.
";
#[test]
fn heading_prepend() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Heading,
"Introduction",
"Prepended line.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| *l == "# Introduction").unwrap();
assert_eq!(lines[idx + 1], "Prepended line.");
assert_eq!(lines[idx + 2], "This is the intro.");
}
#[test]
fn heading_append() {
let r = req(
PatchOperation::Append,
PatchTargetType::Heading,
"Details",
"Appended to details.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let summary_idx = lines.iter().position(|l| *l == "## Summary").unwrap();
assert_eq!(lines[summary_idx - 1], "Appended to details.");
}
#[test]
fn heading_replace() {
let r = req(
PatchOperation::Replace,
PatchTargetType::Heading,
"Summary",
"New summary content.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| *l == "## Summary").unwrap();
assert_eq!(lines[idx + 1], "New summary content.");
assert!(!result.contains("Summary text."));
assert!(result.contains("# Conclusion"));
}
#[test]
fn heading_nested_path() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Heading,
"Introduction::Details",
"Nested prepend.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| *l == "## Details").unwrap();
assert_eq!(lines[idx + 1], "Nested prepend.");
}
#[test]
fn heading_deeply_nested() {
let r = req(
PatchOperation::Replace,
PatchTargetType::Heading,
"Introduction::Details::Sub Details",
"Replaced sub content.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
assert!(result.contains("Replaced sub content."));
assert!(!result.contains("Sub detail content ^block2"));
assert!(result.contains("## Summary"));
}
#[test]
fn heading_missing() {
let r = req(
PatchOperation::Append,
PatchTargetType::Heading,
"Nonexistent",
"content",
);
let err = apply_patch(SAMPLE, &r, p()).unwrap_err();
match err {
VaultError::PatchTargetNotFound { target, .. } => {
assert_eq!(target, "Nonexistent");
}
other => panic!("Expected PatchTargetNotFound, got {other:?}"),
}
}
#[test]
fn block_prepend() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Block,
"block1",
"Before block.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| l.contains("^block1")).unwrap();
assert_eq!(lines[idx - 1], "Before block.");
}
#[test]
fn block_append() {
let r = req(
PatchOperation::Append,
PatchTargetType::Block,
"block1",
"After block.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| l.contains("^block1")).unwrap();
assert_eq!(lines[idx + 1], "After block.");
}
#[test]
fn block_replace() {
let r = req(
PatchOperation::Replace,
PatchTargetType::Block,
"block1",
"New block text",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
assert!(result.contains("New block text ^block1"));
assert!(!result.contains("Important detail ^block1"));
}
#[test]
fn block_with_caret_prefix() {
let r = req(
PatchOperation::Append,
PatchTargetType::Block,
"^block2",
"After block2.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let lines: Vec<&str> = result.lines().collect();
let idx = lines.iter().position(|l| l.contains("^block2")).unwrap();
assert_eq!(lines[idx + 1], "After block2.");
}
#[test]
fn block_missing() {
let r = req(
PatchOperation::Append,
PatchTargetType::Block,
"nonexistent",
"content",
);
assert!(matches!(
apply_patch(SAMPLE, &r, p()),
Err(VaultError::PatchTargetNotFound { .. })
));
}
#[test]
fn block_ref_standalone_replace() {
let content = "First line\n^solo\nLast line\n";
let r = req(
PatchOperation::Replace,
PatchTargetType::Block,
"solo",
"Replaced",
);
let result = apply_patch(content, &r, p()).unwrap();
assert!(result.contains("Replaced ^solo"));
assert!(result.contains("First line"));
assert!(result.contains("Last line"));
}
#[test]
fn frontmatter_replace() {
let r = req(
PatchOperation::Replace,
PatchTargetType::Frontmatter,
"title",
"New Title",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
assert_eq!(yaml["title"].as_str().unwrap(), "New Title");
assert!(result.contains("# Introduction"));
}
#[test]
fn frontmatter_append_list() {
let r = req(
PatchOperation::Append,
PatchTargetType::Frontmatter,
"tags",
"obsidian",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
let tags = yaml["tags"].as_sequence().unwrap();
let texts: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
assert!(texts.contains(&"rust"));
assert!(texts.contains(&"mcp"));
assert!(texts.contains(&"obsidian"));
assert_eq!(*texts.last().unwrap(), "obsidian");
}
#[test]
fn frontmatter_prepend_list() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Frontmatter,
"tags",
"first",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
let tags = yaml["tags"].as_sequence().unwrap();
assert_eq!(tags[0].as_str().unwrap(), "first");
}
#[test]
fn frontmatter_append_string() {
let r = req(
PatchOperation::Append,
PatchTargetType::Frontmatter,
"title",
" (v2)",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
assert_eq!(yaml["title"].as_str().unwrap(), "Test Note (v2)");
}
#[test]
fn frontmatter_prepend_string() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Frontmatter,
"title",
"Prefix: ",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
assert_eq!(yaml["title"].as_str().unwrap(), "Prefix: Test Note");
}
#[test]
fn frontmatter_new_field() {
let r = req(
PatchOperation::Replace,
PatchTargetType::Frontmatter,
"status",
"draft",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
let fm = frontmatter_boundaries(&result).unwrap();
let yaml: serde_yaml::Value =
serde_yaml::from_str(&result[fm.yaml_start..fm.yaml_end]).unwrap();
assert_eq!(yaml["status"].as_str().unwrap(), "draft");
}
#[test]
fn frontmatter_missing_block() {
let content = "# No frontmatter\nJust content.\n";
let r = req(
PatchOperation::Replace,
PatchTargetType::Frontmatter,
"key",
"value",
);
assert!(matches!(
apply_patch(content, &r, p()),
Err(VaultError::PatchTargetNotFound { .. })
));
}
#[test]
fn heading_inside_code_block_ignored() {
let content = "# Real Heading\nSome text.\n```\n# Not A Heading\n```\n# After Code\n";
let headings = find_headings(content);
let texts: Vec<&str> = headings.iter().map(|h| h.text).collect();
assert_eq!(texts, vec!["Real Heading", "After Code"]);
}
#[test]
fn block_inside_code_block_ignored() {
let content = "Start\n```\nfake ^block9\n```\nReal ^block9\n";
assert_eq!(find_block_ref_line(content, "block9"), Some(4));
}
#[test]
fn content_preservation() {
let r = req(
PatchOperation::Prepend,
PatchTargetType::Heading,
"Conclusion",
"Added.",
);
let result = apply_patch(SAMPLE, &r, p()).unwrap();
assert!(result.contains("This is the intro."));
assert!(result.contains("Some details here."));
assert!(result.contains("Summary text."));
assert!(result.contains("Important detail ^block1"));
}
#[test]
fn heading_at_end_of_file() {
let content = "# Only Heading\n";
let r = req(
PatchOperation::Append,
PatchTargetType::Heading,
"Only Heading",
"Content added.",
);
let result = apply_patch(content, &r, p()).unwrap();
assert!(result.contains("# Only Heading"));
assert!(result.contains("Content added."));
}
#[test]
fn heading_case_insensitive() {
let content = "# My Heading\nBody text.\n";
let r = req(
PatchOperation::Append,
PatchTargetType::Heading,
"my heading",
"Appended.",
);
let result = apply_patch(content, &r, p()).unwrap();
assert!(result.contains("Appended."));
}
#[test]
fn frontmatter_body_preserved_exactly() {
let content = "---\nkey: val\n---\nBody with special chars: *bold* [[link]] #tag\n";
let r = req(
PatchOperation::Replace,
PatchTargetType::Frontmatter,
"key",
"new",
);
let result = apply_patch(content, &r, p()).unwrap();
assert!(result.contains("Body with special chars: *bold* [[link]] #tag"));
}
#[test]
fn frontmatter_yaml_comment_not_heading() {
let content = "---\n# yaml comment\nkey: val\n---\n# Real Heading\n";
let headings = find_headings(content);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].text, "Real Heading");
}
}