use crate::error::{Result, VaultdbError};
use crate::record::Value;
#[derive(Debug)]
pub enum ChangeDescription {
SetField {
field: String,
old_value: String,
new_value: String,
},
UnsetField {
field: String,
old_value: String,
},
AddTag {
tag: String,
},
RemoveTag {
tag: String,
},
SetBody {
old_len: usize,
new_len: usize,
},
AppendBody {
added_len: usize,
},
ClearBody {
old_len: usize,
},
}
impl std::fmt::Display for ChangeDescription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChangeDescription::SetField {
field,
old_value,
new_value,
} => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
ChangeDescription::UnsetField { field, old_value } => {
write!(f, "unset {} (was: {})", field, old_value)
}
ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
ChangeDescription::SetBody { old_len, new_len } => {
write!(f, "set body ({} → {} bytes)", old_len, new_len)
}
ChangeDescription::AppendBody { added_len } => {
write!(f, "append body (+{} bytes)", added_len)
}
ChangeDescription::ClearBody { old_len } => {
write!(f, "clear body (was {} bytes)", old_len)
}
}
}
}
pub struct WriteResult {
pub path: std::path::PathBuf,
pub original_content: String,
pub modified_content: String,
pub changes: Vec<ChangeDescription>,
}
fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || lines[0].trim() != "---" {
return Ok((vec!["---", "---"], content));
}
let close_idx = lines[1..]
.iter()
.position(|l| l.trim() == "---")
.map(|i| i + 1);
match close_idx {
Some(idx) => {
let fm_lines = &lines[..=idx];
let mut byte_offset = 0;
for (i, line) in content.lines().enumerate() {
byte_offset += line.len();
if byte_offset < content.len() {
if content.as_bytes().get(byte_offset) == Some(&b'\r') {
byte_offset += 1; }
if byte_offset < content.len() {
byte_offset += 1; }
}
if i == idx {
break;
}
}
let body = &content[byte_offset..];
Ok((fm_lines.to_vec(), body))
}
None => Err(VaultdbError::NoFrontmatter("content".into())),
}
}
fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
for line in fm_lines.iter().skip(key_line_idx + 1) {
let trimmed = line.trim();
if trimmed == "---"
|| (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
{
break;
}
if trimmed.starts_with("- ") || trimmed == "-" {
let dash_pos = line.find('-').unwrap();
let prefix = &line[..dash_pos];
return format!("{}- ", prefix);
}
}
" - ".to_string()
}
fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
let patterns = [format!("{}:", key), format!("{} :", key)];
for (i, line) in fm_lines.iter().enumerate() {
if i == 0 || line.trim() == "---" {
continue; }
let trimmed = line.trim_start();
for pattern in &patterns {
if trimmed.starts_with(pattern) {
let after = &trimmed[pattern.len()..];
if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
return Some(i);
}
}
}
}
None
}
fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
let key_line = fm_lines[key_line_idx];
let key_indent = key_line.len() - key_line.trim_start().len();
let after_colon = key_line.trim_start();
if let Some(colon_pos) = after_colon.find(':') {
let value_part = after_colon[colon_pos + 1..].trim();
if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
return 1;
}
}
let mut extent = 1;
for line in fm_lines.iter().skip(key_line_idx + 1) {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if trimmed.is_empty() {
break;
}
let line_indent = line.len() - line.trim_start().len();
if line_indent <= key_indent && !trimmed.starts_with('-') {
break;
}
if line_indent == key_indent && trimmed.starts_with('-') {
extent += 1;
continue;
}
if line_indent > key_indent {
extent += 1;
continue;
}
break;
}
extent
}
fn is_flow_style_list(line: &str) -> bool {
if let Some(colon_pos) = line.find(':') {
let value = line[colon_pos + 1..].trim();
value.starts_with('[') && value.ends_with(']')
} else {
false
}
}
fn is_multiline_scalar(line: &str) -> bool {
if let Some(colon_pos) = line.find(':') {
let value = line[colon_pos + 1..].trim();
value == "|"
|| value == ">"
|| value == "|+"
|| value == "|-"
|| value == ">+"
|| value == ">-"
} else {
false
}
}
pub fn quote_value(value: &str) -> String {
yaml_quote_value(value)
}
fn yaml_quote_value(value: &str) -> String {
let needs_quoting = value.contains(':')
|| value.contains('#')
|| value.contains('[')
|| value.contains(']')
|| value.contains('{')
|| value.contains('}')
|| value.contains('\'')
|| value.contains('"')
|| value.contains('&')
|| value.contains('*')
|| value.contains('!')
|| value.contains('|')
|| value.contains('>')
|| value.contains('%')
|| value.contains('@')
|| value.starts_with(' ')
|| value.ends_with(' ')
|| value.starts_with('-')
|| value.starts_with('?')
|| is_yaml_type_ambiguous_bare_scalar(value);
if needs_quoting {
if value.contains('\'') {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
format!("'{}'", value)
}
} else {
value.to_string()
}
}
fn is_yaml_type_ambiguous_bare_scalar(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
if matches!(
lower.as_str(),
"true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
) {
return true;
}
if !value.is_empty() && value.parse::<f64>().is_ok() {
return true;
}
false
}
pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
let quoted_value = yaml_quote_value(value);
set_field_with_formatted(content, key, "ed_value, value)
}
pub fn set_field_preformatted(
content: &str,
key: &str,
yaml_value: &str,
) -> Result<(String, ChangeDescription)> {
set_field_with_formatted(content, key, yaml_value, yaml_value)
}
fn set_field_with_formatted(
content: &str,
key: &str,
formatted_value: &str,
change_value: &str,
) -> Result<(String, ChangeDescription)> {
let (fm_lines, body) = split_frontmatter(content)?;
if let Some(key_idx) = find_key_line(&fm_lines, key) {
if is_flow_style_list(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!(
"field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
key
),
});
}
if is_multiline_scalar(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!(
"field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
key
),
});
}
let extent = field_extent(&fm_lines, key_idx);
let old_value = if extent == 1 {
let old_line = fm_lines[key_idx];
old_line
.find(':')
.map(|pos| old_line[pos + 1..].trim())
.unwrap_or("")
.to_string()
} else {
fm_lines[key_idx + 1..key_idx + extent]
.iter()
.map(|l| l.trim().trim_start_matches('-').trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ")
};
let new_line = format!("{}: {}", key, formatted_value);
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
if i == key_idx {
result_lines.push(new_line.clone());
} else if i > key_idx && i < key_idx + extent {
continue; } else {
result_lines.push(line.to_string());
}
}
let change = ChangeDescription::SetField {
field: key.to_string(),
old_value,
new_value: change_value.to_string(),
};
Ok((reassemble(&result_lines, body, content), change))
} else {
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
if i == fm_lines.len() - 1 && line.trim() == "---" {
result_lines.push(format!("{}: {}", key, formatted_value));
}
result_lines.push(line.to_string());
}
let change = ChangeDescription::SetField {
field: key.to_string(),
old_value: String::new(),
new_value: change_value.to_string(),
};
Ok((reassemble(&result_lines, body, content), change))
}
}
pub fn set_field_block(
content: &str,
key: &str,
value: &Value,
) -> Result<(String, ChangeDescription)> {
if !matches!(value, Value::List(_) | Value::Map(_)) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!(
"set_field_block called with a scalar value for '{}'; use set_field instead",
key
),
});
}
let (fm_lines, body) = split_frontmatter(content)?;
let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
wrapper.insert(key.to_string(), value.clone());
let rendered =
serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!("rendering '{}' as YAML: {}", key, e),
})?;
let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
let new_value_summary = serde_yaml::to_string(value)
.map(|s| s.trim_end().to_string())
.unwrap_or_default();
if let Some(key_idx) = find_key_line(&fm_lines, key) {
if is_flow_style_list(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!(
"field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
key
),
});
}
if is_multiline_scalar(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!(
"field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
key
),
});
}
let extent = field_extent(&fm_lines, key_idx);
let old_value = if extent == 1 {
fm_lines[key_idx]
.find(':')
.map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
.unwrap_or_default()
} else {
fm_lines[key_idx..key_idx + extent].join("\n")
};
let mut result_lines: Vec<String> = Vec::new();
for line in &fm_lines[..key_idx] {
result_lines.push((*line).to_string());
}
result_lines.extend(new_lines.iter().cloned());
for line in &fm_lines[key_idx + extent..] {
result_lines.push((*line).to_string());
}
let change = ChangeDescription::SetField {
field: key.to_string(),
old_value,
new_value: new_value_summary,
};
Ok((reassemble(&result_lines, body, content), change))
} else {
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
if i == fm_lines.len() - 1 && line.trim() == "---" {
result_lines.extend(new_lines.iter().cloned());
}
result_lines.push((*line).to_string());
}
let change = ChangeDescription::SetField {
field: key.to_string(),
old_value: String::new(),
new_value: new_value_summary,
};
Ok((reassemble(&result_lines, body, content), change))
}
}
pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
let (fm_lines, body) = split_frontmatter(content)?;
let key_idx =
find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!("field '{}' not found", key),
})?;
let extent = field_extent(&fm_lines, key_idx);
let old_value = fm_lines[key_idx]
.find(':')
.map(|pos| fm_lines[key_idx][pos + 1..].trim())
.unwrap_or("")
.to_string();
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
if i >= key_idx && i < key_idx + extent {
continue; }
result_lines.push(line.to_string());
}
let change = ChangeDescription::UnsetField {
field: key.to_string(),
old_value,
};
Ok((reassemble(&result_lines, body, content), change))
}
pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
let (fm_lines, body) = split_frontmatter(content)?;
let key_idx =
find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: "no 'tags' field found".into(),
})?;
if is_flow_style_list(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
});
}
let indent_prefix = detect_list_indent(&fm_lines, key_idx);
let extent = field_extent(&fm_lines, key_idx);
let insert_after = key_idx + extent - 1;
let new_tag_line = format!("{}{}", indent_prefix, tag);
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
result_lines.push(line.to_string());
if i == insert_after {
result_lines.push(new_tag_line.clone());
}
}
let change = ChangeDescription::AddTag {
tag: tag.to_string(),
};
Ok((reassemble(&result_lines, body, content), change))
}
pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
let (fm_lines, body) = split_frontmatter(content)?;
let key_idx =
find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: "no 'tags' field found".into(),
})?;
if is_flow_style_list(fm_lines[key_idx]) {
return Err(VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
});
}
let extent = field_extent(&fm_lines, key_idx);
let tag_line_idx = fm_lines
.iter()
.enumerate()
.skip(key_idx + 1)
.take(extent.saturating_sub(1))
.find_map(|(i, line)| {
let trimmed = line.trim();
let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
(tag_value == tag).then_some(i)
});
let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
file: String::new(),
reason: format!("tag '{}' not found in tags list", tag),
})?;
let mut result_lines: Vec<String> = Vec::new();
for (i, line) in fm_lines.iter().enumerate() {
if i == tag_line_idx {
continue;
}
result_lines.push(line.to_string());
}
let change = ChangeDescription::RemoveTag {
tag: tag.to_string(),
};
Ok((reassemble(&result_lines, body, content), change))
}
pub fn set_body(content: &str, new_body: &str) -> Result<(String, ChangeDescription)> {
let (fm_lines, old_body) = split_frontmatter(content)?;
let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
let change = ChangeDescription::SetBody {
old_len: old_body.len(),
new_len: new_body.len(),
};
Ok((reassemble(&fm_owned, new_body, content), change))
}
pub fn clear_body(content: &str) -> Result<(String, ChangeDescription)> {
let (fm_lines, old_body) = split_frontmatter(content)?;
let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
let change = ChangeDescription::ClearBody {
old_len: old_body.len(),
};
Ok((reassemble(&fm_owned, "", content), change))
}
pub fn append_body(
content: &str,
text: &str,
separator: &str,
) -> Result<(String, ChangeDescription)> {
let (fm_lines, old_body) = split_frontmatter(content)?;
let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
let new_body = if old_body.is_empty() {
text.to_string()
} else {
let trimmed = old_body.trim_end_matches(['\n', '\r']);
format!("{}{}{}", trimmed, separator, text)
};
let change = ChangeDescription::AppendBody {
added_len: text.len(),
};
Ok((reassemble(&fm_owned, &new_body, content), change))
}
fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
let line_ending = if original.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let mut result = fm_lines.join(line_ending);
result.push_str(line_ending);
result.push_str(body);
result
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WriteOptions {
pub fsync: bool,
}
impl WriteOptions {
pub fn durable() -> Self {
Self { fsync: true }
}
}
pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
let f = std::fs::File::open(dir)?;
f.sync_all()
}
pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
atomic_write_with(path, content, WriteOptions::default())
}
pub fn atomic_create_with(
path: &std::path::Path,
content: &str,
opts: WriteOptions,
) -> std::io::Result<()> {
let dir = path.parent().ok_or_else(|| {
std::io::Error::other(format!(
"atomic_create target has no parent dir: {}",
path.display()
))
})?;
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
use std::io::Write;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
if opts.fsync {
tmp.as_file().sync_all()?;
}
tmp.persist_noclobber(path).map_err(|e| e.error)?;
if opts.fsync {
fsync_dir(dir)?;
}
Ok(())
}
pub fn atomic_write_with(
path: &std::path::Path,
content: &str,
opts: WriteOptions,
) -> std::io::Result<()> {
let dir = path.parent().ok_or_else(|| {
std::io::Error::other(format!(
"atomic_write target has no parent dir: {}",
path.display()
))
})?;
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
use std::io::Write;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
if opts.fsync {
tmp.as_file().sync_all()?;
}
tmp.persist(path).map_err(|e| e.error)?;
if opts.fsync {
fsync_dir(dir)?;
}
Ok(())
}
pub fn apply(result: &WriteResult) -> std::io::Result<()> {
apply_with(result, WriteOptions::default())
}
pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
atomic_write_with(&result.path, &result.modified_content, opts)
}
#[cfg(test)]
mod tests {
use super::*;
const MOVIE_FILE: &str = "\
---
aliases:
tags:
- type/leaf
- topic/movies
- source/video
- genre/drama
status: to-watch
rating:
director: Sam Mendes
year: 2019
related-to:
---
Part of [[Watchlist]]
";
const CHINESE_FILE: &str = "\
---
aliases:
- kuài
tags:
- type/concept
- topic/chinese
- source/self-study
pinyin: kuài
anlam: hızlı
tür: sifat
hsk: 1
kaliplar:
- kalip: 快乐
pinyin: kuàilè
anlam: mutlu, neşeli
ornekler:
- cumle: 他跑得很快。
pinyin: Tā pǎo de hěn kuài.
anlam: O çok hızlı koşuyor.
related-to:
---
# 快 (kuài) — hızlı
Body text.
";
#[test]
fn set_existing_scalar_field() {
let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
assert!(result.contains("status: watched"));
assert!(!result.contains("to-watch"));
assert!(result.contains("Part of [[Watchlist]]"));
match change {
ChangeDescription::SetField {
field,
old_value,
new_value,
} => {
assert_eq!(field, "status");
assert_eq!(old_value, "to-watch");
assert_eq!(new_value, "watched");
}
_ => panic!("expected SetField"),
}
}
#[test]
fn set_null_field() {
let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
assert!(result.contains("rating: '8'"), "got:\n{}", result);
}
#[test]
fn set_new_field() {
let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
assert!(result.contains("language: English"));
let closing_idx = result.rfind("\n---\n").unwrap();
let lang_idx = result.find("language: English").unwrap();
assert!(lang_idx < closing_idx);
}
#[test]
fn set_scalar_over_block_field_replaces() {
let (result, change) = set_field(CHINESE_FILE, "kaliplar", "something").unwrap();
assert!(result.contains("kaliplar: something"), "got:\n{}", result);
assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè"));
assert!(result.contains("hsk: 1"));
assert!(result.contains("ornekler:"));
assert!(result.contains("Body text."));
match change {
ChangeDescription::SetField {
field, new_value, ..
} => {
assert_eq!(field, "kaliplar");
assert_eq!(new_value, "something");
}
_ => panic!("expected SetField"),
}
}
#[test]
fn set_field_initializes_frontmatter_on_bare_file() {
let bare = "# Just a heading\n\nSome body text.\n";
let (result, _) = set_field(bare, "db-table", "rusen-wiki").unwrap();
assert!(result.starts_with("---\n"), "got:\n{}", result);
assert!(result.contains("db-table: rusen-wiki"));
assert!(result.contains("# Just a heading"));
assert!(result.contains("Some body text."));
let fm_end = result[4..].find("\n---\n").unwrap() + 4;
let fm = &result[4..fm_end];
let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
assert_eq!(
parsed
.as_mapping()
.and_then(|m| m.get("db-table"))
.and_then(|v| v.as_str()),
Some("rusen-wiki")
);
}
#[test]
fn set_value_needing_quotes() {
let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
assert!(result.contains("note: 'key: value'"));
}
#[test]
fn set_field_block_inserts_new_list_as_block_yaml() {
let value = Value::List(vec![Value::String("kedi".into())]);
let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
assert!(result.contains("anlamlar:\n- kedi"));
assert!(!result.contains("anlamlar: '- kedi'"));
let closing_idx = result.rfind("\n---\n").unwrap();
assert!(result.find("anlamlar:").unwrap() < closing_idx);
match change {
ChangeDescription::SetField {
field, new_value, ..
} => {
assert_eq!(field, "anlamlar");
assert_eq!(new_value.trim_end(), "- kedi");
}
_ => panic!("expected SetField"),
}
}
#[test]
fn set_field_block_multi_item_list_round_trips() {
let value = Value::List(vec![
Value::String("猫が好きです。".into()),
Value::String("私の猫は黒いです。".into()),
]);
let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
let fm_end = result[4..].find("\n---\n").unwrap() + 4;
let fm = &result[4..fm_end];
let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
let items = parsed
.as_mapping()
.and_then(|m| m.get("ornekler_jp"))
.and_then(|v| v.as_sequence())
.expect("ornekler_jp must round-trip as a YAML sequence");
assert_eq!(items.len(), 2);
}
#[test]
fn set_field_block_replaces_existing_block_list() {
let value = Value::List(vec![Value::String("replaced".into())]);
let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
assert!(result.contains("kaliplar:\n- replaced"));
assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè")); assert!(result.contains("hsk: 1"));
assert!(result.contains("ornekler:"));
}
#[test]
fn set_field_block_writes_map_as_nested_yaml() {
let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
m.insert("k1".into(), Value::String("v1".into()));
m.insert("k2".into(), Value::Integer(2));
let value = Value::Map(m);
let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
assert!(result.contains("meta:\n k1: v1\n k2: 2"));
}
#[test]
fn set_field_block_rejects_flow_style_existing() {
let content = "---\ntags: [a, b]\n---\nbody\n";
let value = Value::List(vec![Value::String("c".into())]);
let err = set_field_block(content, "tags", &value).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("flow-style"), "got: {}", msg);
}
#[test]
fn set_field_block_rejects_scalar_value() {
let err =
set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("scalar value"), "got: {}", msg);
}
#[test]
fn unset_scalar_field() {
let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
assert!(!result.contains("director:"));
assert!(result.contains("status: to-watch"));
assert!(result.contains("year: 2019"));
assert!(result.contains("Part of [[Watchlist]]"));
}
#[test]
fn unset_list_field() {
let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
assert!(!result.contains("kaliplar:"));
assert!(!result.contains("快乐"));
assert!(result.contains("pinyin: kuài"));
assert!(result.contains("Body text."));
}
#[test]
fn unset_nonexistent_field() {
let result = unset_field(MOVIE_FILE, "nonexistent");
assert!(result.is_err());
}
#[test]
fn add_tag_2space_indent() {
let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
assert!(result.contains(" - genre/war"));
assert!(result.contains(" - type/leaf"));
assert!(result.contains(" - genre/drama"));
}
#[test]
fn add_tag_0indent() {
let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
assert!(result.contains("- topic/hsk1"));
assert!(result.contains("- type/concept"));
assert!(result.contains("- topic/chinese"));
}
#[test]
fn remove_tag_2space_indent() {
let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
assert!(!result.contains("genre/drama"));
assert!(result.contains(" - type/leaf"));
assert!(result.contains(" - source/video"));
}
#[test]
fn remove_tag_0indent() {
let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
assert!(!result.contains("topic/chinese"));
assert!(result.contains("- type/concept"));
assert!(result.contains("- source/self-study"));
}
#[test]
fn remove_nonexistent_tag() {
let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
assert!(result.is_err());
}
#[test]
fn body_preserved_after_set() {
let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
assert!(result.ends_with("Part of [[Watchlist]]\n"));
}
#[test]
fn body_preserved_after_unset() {
let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
assert!(result.contains("# 快 (kuài) — hızlı"));
assert!(result.contains("Body text."));
}
#[test]
fn body_preserved_after_add_tag() {
let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
assert!(result.contains("# 快 (kuài) — hızlı"));
}
#[test]
fn chinese_content_preserved() {
let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
assert!(result.contains("pinyin: kuài"));
assert!(result.contains("anlam: hızlı"));
assert!(result.contains("tür: sifat"));
assert!(result.contains("kalip: 快乐"));
assert!(result.contains("cumle: 他跑得很快。"));
}
#[test]
fn set_field_rejects_flow_style() {
let content = "---\ntags: [a, b, c]\n---\nBody.\n";
let result = set_field(content, "tags", "x");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("flow-style"));
}
#[test]
fn set_field_rejects_multiline_scalar() {
let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
let result = set_field(content, "description", "new value");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("multiline"));
}
#[test]
fn add_tag_rejects_flow_style() {
let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
let result = add_tag(content, "topic/new");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("flow-style"));
}
#[test]
fn remove_tag_rejects_flow_style() {
let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
let result = remove_tag(content, "topic/ai");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("flow-style"));
}
#[test]
fn atomic_create_refuses_to_overwrite_existing_file() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("note.md");
fs::write(&target, "existing content\n").unwrap();
let err = atomic_create_with(&target, "would clobber\n", WriteOptions::default())
.expect_err("atomic_create must refuse to overwrite");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&target).unwrap(), "existing content\n");
}
#[test]
fn atomic_create_writes_to_new_path() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("fresh.md");
atomic_create_with(&target, "hello\n", WriteOptions::default()).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
}
#[test]
fn set_field_preformatted_writes_value_verbatim() {
let content = "---\nurl:\n---\nBody\n";
let preformatted = "'https://www.amazon.com.tr/foo'";
let (out, _) = set_field_preformatted(content, "url", preformatted).unwrap();
assert!(
out.contains("url: 'https://www.amazon.com.tr/foo'"),
"got:\n{}",
out
);
assert!(
!out.contains("url: \"'"),
"preformatted value was double-quoted; got:\n{}",
out
);
}
#[test]
fn set_body_replaces_existing_body() {
let (result, change) = set_body(MOVIE_FILE, "New body content.\n").unwrap();
assert!(result.contains("director: Sam Mendes"));
assert!(result.contains("status: to-watch"));
assert!(!result.contains("Part of [[Watchlist]]"));
assert!(result.contains("New body content."));
assert!(result.ends_with("---\nNew body content.\n"));
match change {
ChangeDescription::SetBody { new_len, .. } => assert_eq!(new_len, 18),
other => panic!("expected SetBody, got {:?}", other),
}
}
#[test]
fn set_body_writes_verbatim_no_trailing_newline_added() {
let (result, _) = set_body(MOVIE_FILE, "no newline").unwrap();
assert!(result.ends_with("---\nno newline"));
}
#[test]
fn set_body_on_frontmatter_only_file() {
let fm_only = "---\nstatus: x\n---\n";
let (result, _) = set_body(fm_only, "Hello.\n").unwrap();
assert_eq!(result, "---\nstatus: x\n---\nHello.\n");
}
#[test]
fn set_body_on_bare_file_synthesizes_frontmatter() {
let bare = "Just a bare note.\n";
let (result, change) = set_body(bare, "Replaced.\n").unwrap();
assert!(result.starts_with("---\n---\n"));
assert!(result.ends_with("Replaced.\n"));
assert!(!result.contains("Just a bare note"));
match change {
ChangeDescription::SetBody { old_len, new_len } => {
assert_eq!(old_len, bare.len());
assert_eq!(new_len, "Replaced.\n".len());
}
other => panic!("expected SetBody, got {:?}", other),
}
}
#[test]
fn clear_body_keeps_frontmatter_and_drops_body() {
let (result, change) = clear_body(MOVIE_FILE).unwrap();
assert!(result.contains("director: Sam Mendes"));
assert!(!result.contains("Part of [[Watchlist]]"));
assert!(result.ends_with("---\n"));
match change {
ChangeDescription::ClearBody { old_len } => assert!(old_len > 0),
other => panic!("expected ClearBody, got {:?}", other),
}
}
#[test]
fn append_body_on_existing_body_uses_separator() {
let (result, change) = append_body(MOVIE_FILE, "Next line.", "\n").unwrap();
assert!(result.contains("Part of [[Watchlist]]"));
assert!(result.ends_with("Part of [[Watchlist]]\nNext line."));
match change {
ChangeDescription::AppendBody { added_len } => assert_eq!(added_len, 10),
other => panic!("expected AppendBody, got {:?}", other),
}
}
#[test]
fn append_body_with_custom_separator() {
let fm_with_body = "---\nstatus: x\n---\nFirst.\n";
let (result, _) = append_body(fm_with_body, "Second.", "\n\n").unwrap();
assert!(result.ends_with("First.\n\nSecond."));
}
#[test]
fn append_body_on_empty_body_skips_separator() {
let fm_only = "---\nstatus: x\n---\n";
let (result, _) = append_body(fm_only, "First line.", "\n").unwrap();
assert_eq!(result, "---\nstatus: x\n---\nFirst line.");
}
#[test]
fn append_body_idempotent_against_trailing_newlines() {
let start = "---\n---\n";
let (r1, _) = append_body(start, "a\n", "\n").unwrap();
let (r2, _) = append_body(&r1, "b\n", "\n").unwrap();
let (r3, _) = append_body(&r2, "c\n", "\n").unwrap();
assert_eq!(r3, "---\n---\na\nb\nc\n");
}
#[test]
fn append_body_on_bare_file_appends_after_original() {
let bare = "Existing.\n";
let (result, _) = append_body(bare, "More.", "\n").unwrap();
assert!(result.starts_with("---\n---\n"));
assert!(result.ends_with("Existing.\nMore."));
}
#[test]
fn set_field_still_quotes_raw_values() {
let content = "---\nurl:\n---\n";
let (out, _) = set_field(content, "url", "https://www.example.com").unwrap();
assert!(
out.contains("url: 'https://www.example.com'"),
"got:\n{}",
out
);
assert!(!out.contains("url: \"'"), "got:\n{}", out);
}
}