Skip to main content

vaultdb_core/
writer.rs

1//! Frontmatter write primitives. [`set_field`], [`unset_field`], [`add_tag`],
2//! [`remove_tag`] each return `(new_content, ChangeDescription)` without
3//! touching disk; [`apply`] flushes a [`WriteResult`] to the filesystem. The
4//! public mutation builders in [`crate::mutation`] wrap these.
5
6use crate::error::{Result, VaultdbError};
7use crate::record::Value;
8
9/// Describes a single change made to a file.
10#[derive(Debug)]
11pub enum ChangeDescription {
12    SetField {
13        field: String,
14        old_value: String,
15        new_value: String,
16    },
17    UnsetField {
18        field: String,
19        old_value: String,
20    },
21    AddTag {
22        tag: String,
23    },
24    RemoveTag {
25        tag: String,
26    },
27    /// Replace the entire body (everything after the closing `---` of the
28    /// frontmatter). `old_len` / `new_len` are byte counts, surfaced so
29    /// the report can say "shrank body from 1.2k → 80 bytes" without
30    /// echoing arbitrary user prose.
31    SetBody {
32        old_len: usize,
33        new_len: usize,
34    },
35    /// Append text to the existing body (separated by a caller-chosen
36    /// separator, default `"\n"`). `added_len` is the byte count of the
37    /// appended text (excluding separator) for the same reason.
38    AppendBody {
39        added_len: usize,
40    },
41    /// Clear the body entirely — semantic shorthand for "set to empty".
42    /// Carries `old_len` so an audit log can record what was removed.
43    ClearBody {
44        old_len: usize,
45    },
46}
47
48impl std::fmt::Display for ChangeDescription {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            ChangeDescription::SetField {
52                field,
53                old_value,
54                new_value,
55            } => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
56            ChangeDescription::UnsetField { field, old_value } => {
57                write!(f, "unset {} (was: {})", field, old_value)
58            }
59            ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
60            ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
61            ChangeDescription::SetBody { old_len, new_len } => {
62                write!(f, "set body ({} → {} bytes)", old_len, new_len)
63            }
64            ChangeDescription::AppendBody { added_len } => {
65                write!(f, "append body (+{} bytes)", added_len)
66            }
67            ChangeDescription::ClearBody { old_len } => {
68                write!(f, "clear body (was {} bytes)", old_len)
69            }
70        }
71    }
72}
73
74/// Result of a write operation on a single file.
75pub struct WriteResult {
76    pub path: std::path::PathBuf,
77    pub original_content: String,
78    pub modified_content: String,
79    pub changes: Vec<ChangeDescription>,
80}
81
82/// Split file content into frontmatter lines and body.
83/// Returns (frontmatter_lines_including_delimiters, body_str).
84fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
85    let lines: Vec<&str> = content.lines().collect();
86
87    if lines.is_empty() || lines[0].trim() != "---" {
88        // No frontmatter block at all: synthesize an empty one and treat the
89        // entire content as the body. This lets the write primitives
90        // (set_field, add_tag, ...) initialize frontmatter on a bare file
91        // instead of refusing. A file that *opens* a frontmatter block but
92        // never closes it is still rejected as malformed (see below).
93        return Ok((vec!["---", "---"], content));
94    }
95
96    // Find closing ---
97    let close_idx = lines[1..]
98        .iter()
99        .position(|l| l.trim() == "---")
100        .map(|i| i + 1); // offset by 1 because we started from lines[1..]
101
102    match close_idx {
103        Some(idx) => {
104            let fm_lines = &lines[..=idx];
105            // Body starts after the closing --- line
106            // We need to find the byte offset of the body
107            let mut byte_offset = 0;
108            for (i, line) in content.lines().enumerate() {
109                byte_offset += line.len();
110                // Account for the newline character
111                if byte_offset < content.len() {
112                    if content.as_bytes().get(byte_offset) == Some(&b'\r') {
113                        byte_offset += 1; // \r
114                    }
115                    if byte_offset < content.len() {
116                        byte_offset += 1; // \n
117                    }
118                }
119                if i == idx {
120                    break;
121                }
122            }
123            let body = &content[byte_offset..];
124            Ok((fm_lines.to_vec(), body))
125        }
126        None => Err(VaultdbError::NoFrontmatter("content".into())),
127    }
128}
129
130/// Detect the indentation used for list items under a key.
131/// Returns the prefix string (e.g., "  - " or "- ").
132fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
133    // Look at the line after the key line
134    for line in fm_lines.iter().skip(key_line_idx + 1) {
135        let trimmed = line.trim();
136
137        // Stop if we hit another top-level key or delimiter
138        if trimmed == "---"
139            || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
140        {
141            break;
142        }
143
144        if trimmed.starts_with("- ") || trimmed == "-" {
145            // Return the actual prefix including whitespace
146            let dash_pos = line.find('-').unwrap();
147            let prefix = &line[..dash_pos];
148            return format!("{}- ", prefix);
149        }
150    }
151    // Default: 2-space indent
152    "  - ".to_string()
153}
154
155/// Find the line index of a top-level key in frontmatter lines (between delimiters).
156fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
157    let patterns = [format!("{}:", key), format!("{} :", key)];
158    for (i, line) in fm_lines.iter().enumerate() {
159        if i == 0 || line.trim() == "---" {
160            continue; // skip delimiters
161        }
162        let trimmed = line.trim_start();
163        for pattern in &patterns {
164            if trimmed.starts_with(pattern) {
165                // Make sure we matched the full key, not a prefix
166                let after = &trimmed[pattern.len()..];
167                if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
168                    return Some(i);
169                }
170            }
171        }
172    }
173    None
174}
175
176/// Determine how many lines a field spans (including nested list/map items).
177fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
178    let key_line = fm_lines[key_line_idx];
179    let key_indent = key_line.len() - key_line.trim_start().len();
180
181    // Check if the key has an inline value (not a list/map)
182    let after_colon = key_line.trim_start();
183    if let Some(colon_pos) = after_colon.find(':') {
184        let value_part = after_colon[colon_pos + 1..].trim();
185        if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
186            // Inline scalar value — single line
187            return 1;
188        }
189    }
190
191    let mut extent = 1;
192    for line in fm_lines.iter().skip(key_line_idx + 1) {
193        let trimmed = line.trim();
194
195        // Stop at closing delimiter
196        if trimmed == "---" {
197            break;
198        }
199
200        // Empty line ends the field
201        if trimmed.is_empty() {
202            break;
203        }
204
205        let line_indent = line.len() - line.trim_start().len();
206
207        // If this line is at the same or lesser indentation and doesn't start with '-',
208        // it's a new top-level key
209        if line_indent <= key_indent && !trimmed.starts_with('-') {
210            break;
211        }
212
213        // Lines starting with '-' at the same indent level are list items of this key
214        if line_indent == key_indent && trimmed.starts_with('-') {
215            extent += 1;
216            continue;
217        }
218
219        // Indented lines are continuations
220        if line_indent > key_indent {
221            extent += 1;
222            continue;
223        }
224
225        break;
226    }
227    extent
228}
229
230/// Check if a field line uses flow-style list syntax: `key: [a, b, c]`
231fn is_flow_style_list(line: &str) -> bool {
232    if let Some(colon_pos) = line.find(':') {
233        let value = line[colon_pos + 1..].trim();
234        value.starts_with('[') && value.ends_with(']')
235    } else {
236        false
237    }
238}
239
240/// Check if a field line uses a multiline scalar indicator: `key: |` or `key: >`
241fn is_multiline_scalar(line: &str) -> bool {
242    if let Some(colon_pos) = line.find(':') {
243        let value = line[colon_pos + 1..].trim();
244        value == "|"
245            || value == ">"
246            || value == "|+"
247            || value == "|-"
248            || value == ">+"
249            || value == ">-"
250    } else {
251        false
252    }
253}
254
255/// Quote a YAML value if it contains special characters.
256pub fn quote_value(value: &str) -> String {
257    yaml_quote_value(value)
258}
259
260fn yaml_quote_value(value: &str) -> String {
261    let needs_quoting = value.contains(':')
262        || value.contains('#')
263        || value.contains('[')
264        || value.contains(']')
265        || value.contains('{')
266        || value.contains('}')
267        || value.contains('\'')
268        || value.contains('"')
269        || value.contains('&')
270        || value.contains('*')
271        || value.contains('!')
272        || value.contains('|')
273        || value.contains('>')
274        || value.contains('%')
275        || value.contains('@')
276        || value.starts_with(' ')
277        || value.ends_with(' ')
278        || value.starts_with('-')
279        || value.starts_with('?')
280        // Type-ambiguous bare scalars: without quoting, these would
281        // parse as a different YAML type when re-read (e.g. `true` →
282        // boolean, `42` → integer, `~` → null). Quote them so a
283        // `Value::String("true")` round-trips as the string "true"
284        // and not the boolean true.
285        || is_yaml_type_ambiguous_bare_scalar(value);
286
287    if needs_quoting {
288        if value.contains('\'') {
289            format!("\"{}\"", value.replace('"', "\\\""))
290        } else {
291            format!("'{}'", value)
292        }
293    } else {
294        value.to_string()
295    }
296}
297
298/// True if `value`, written without YAML quotes, would parse as a
299/// non-string scalar (boolean / null / integer / float). Used by
300/// `yaml_quote_value` to force quoting on these strings so they
301/// round-trip as strings rather than silently changing type.
302fn is_yaml_type_ambiguous_bare_scalar(value: &str) -> bool {
303    // YAML 1.1 boolean / null literals — same set Obsidian / serde_yaml
304    // accept on read. Match case-insensitively to be safe.
305    let lower = value.to_ascii_lowercase();
306    if matches!(
307        lower.as_str(),
308        "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
309    ) {
310        return true;
311    }
312    // Numeric: integer or float. `parse::<f64>` accepts both shapes
313    // including `+1`, `-0.5`, `1e10`, but rejects empty strings.
314    if !value.is_empty() && value.parse::<f64>().is_ok() {
315        return true;
316    }
317    // Leading zero or sign with rest digits could be an int that
318    // f64::parse already covers — no extra branch needed.
319    false
320}
321
322/// Set a scalar field to a new value in the frontmatter. The `value`
323/// is treated as a **raw, unquoted scalar** — `yaml_quote_value` is
324/// applied to it before writing. Use this when you have a plain Rust
325/// string (e.g. `"https://example.com"`, `"true"`) and want it
326/// emitted as a properly-quoted YAML scalar.
327///
328/// For values that are already a valid YAML scalar (e.g. produced by
329/// a higher-level renderer that has already applied quoting rules),
330/// call [`set_field_preformatted`] instead — double-quoting will
331/// otherwise turn `'https://x'` into `"'https://x'"` on disk.
332pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
333    let quoted_value = yaml_quote_value(value);
334    set_field_with_formatted(content, key, &quoted_value, value)
335}
336
337/// Set a scalar field to a value that is **already a valid YAML
338/// scalar**. The string is written verbatim — no extra quoting.
339///
340/// This exists to fix a two-layer-quoting bug introduced by callers
341/// (notably `UpdateBuilder::set` via `render_value_for_yaml`) that
342/// already apply `yaml_quote_value` themselves. If those callers
343/// passed their pre-quoted output to [`set_field`], it would be
344/// quoted a second time — a URL like `https://example.com`, having
345/// already become `'https://example.com'`, would be re-wrapped as
346/// `"'https://example.com'"`. Routing through this function instead
347/// preserves the intended YAML shape.
348///
349/// The caller asserts that `yaml_value` parses as the intended YAML
350/// scalar. If it doesn't, the on-disk file will fail to re-parse;
351/// there is no defence-in-depth check here on purpose, because
352/// guessing whether the input is "already-quoted" or "literal text
353/// containing quote characters" is ambiguous.
354pub fn set_field_preformatted(
355    content: &str,
356    key: &str,
357    yaml_value: &str,
358) -> Result<(String, ChangeDescription)> {
359    set_field_with_formatted(content, key, yaml_value, yaml_value)
360}
361
362/// Shared implementation behind [`set_field`] and
363/// [`set_field_preformatted`]. `formatted_value` is what lands on
364/// disk; `change_value` is what appears in the `ChangeDescription`
365/// surfaced to users / agents (typically the raw, un-quoted form
366/// for `set_field`; the same as `formatted_value` for the
367/// preformatted path).
368fn set_field_with_formatted(
369    content: &str,
370    key: &str,
371    formatted_value: &str,
372    change_value: &str,
373) -> Result<(String, ChangeDescription)> {
374    let (fm_lines, body) = split_frontmatter(content)?;
375
376    if let Some(key_idx) = find_key_line(&fm_lines, key) {
377        // Flow-style lists (`[a, b]`) and multiline scalars (`|`, `>`) are
378        // intentionally not round-tripped — we won't rewrite those shapes.
379        // Block-style lists/maps, however, can be replaced by a scalar: we
380        // drop the whole field span and write the new single line. This lets
381        // a required field that was stored as the wrong (complex) type be
382        // corrected in one set, without an unset that would transiently
383        // violate a "required" constraint.
384        if is_flow_style_list(fm_lines[key_idx]) {
385            return Err(VaultdbError::InvalidFrontmatter {
386                file: String::new(),
387                reason: format!(
388                    "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
389                    key
390                ),
391            });
392        }
393
394        if is_multiline_scalar(fm_lines[key_idx]) {
395            return Err(VaultdbError::InvalidFrontmatter {
396                file: String::new(),
397                reason: format!(
398                    "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
399                    key
400                ),
401            });
402        }
403
404        let extent = field_extent(&fm_lines, key_idx);
405
406        // Prior value, for the ChangeDescription only. Scalar → the text
407        // after the colon; block list/map → its item lines collapsed.
408        let old_value = if extent == 1 {
409            let old_line = fm_lines[key_idx];
410            old_line
411                .find(':')
412                .map(|pos| old_line[pos + 1..].trim())
413                .unwrap_or("")
414                .to_string()
415        } else {
416            fm_lines[key_idx + 1..key_idx + extent]
417                .iter()
418                .map(|l| l.trim().trim_start_matches('-').trim())
419                .filter(|s| !s.is_empty())
420                .collect::<Vec<_>>()
421                .join(", ")
422        };
423
424        let new_line = format!("{}: {}", key, formatted_value);
425
426        // Replace the field's entire extent (key line + any block-style
427        // continuation lines) with the single new scalar line.
428        let mut result_lines: Vec<String> = Vec::new();
429        for (i, line) in fm_lines.iter().enumerate() {
430            if i == key_idx {
431                result_lines.push(new_line.clone());
432            } else if i > key_idx && i < key_idx + extent {
433                continue; // dropped: part of the replaced block
434            } else {
435                result_lines.push(line.to_string());
436            }
437        }
438
439        let change = ChangeDescription::SetField {
440            field: key.to_string(),
441            old_value,
442            new_value: change_value.to_string(),
443        };
444
445        Ok((reassemble(&result_lines, body, content), change))
446    } else {
447        // Key doesn't exist — insert before closing ---
448        let mut result_lines: Vec<String> = Vec::new();
449        for (i, line) in fm_lines.iter().enumerate() {
450            if i == fm_lines.len() - 1 && line.trim() == "---" {
451                result_lines.push(format!("{}: {}", key, formatted_value));
452            }
453            result_lines.push(line.to_string());
454        }
455
456        let change = ChangeDescription::SetField {
457            field: key.to_string(),
458            old_value: String::new(),
459            new_value: change_value.to_string(),
460        };
461
462        Ok((reassemble(&result_lines, body, content), change))
463    }
464}
465
466/// Set a field to a `Value::List` or `Value::Map`, emitting block-style YAML
467/// across multiple lines.
468///
469/// Use [`set_field`] for scalars. This function is the structured counterpart:
470/// it preserves the typed shape of the value through the write rather than
471/// flattening it to a quoted string scalar.
472///
473/// Behavior:
474/// - Key absent → insert the rendered block before the closing `---`.
475/// - Key present as a block-style list/map → replace the multi-line span.
476/// - Key present as a scalar → replace the single line with the block.
477/// - Key present as flow-style (`[a, b]`) or a multiline scalar (`|`, `>`):
478///   refuses with `InvalidFrontmatter`, matching [`set_field`]'s stance —
479///   we won't try to round-trip those styles.
480pub fn set_field_block(
481    content: &str,
482    key: &str,
483    value: &Value,
484) -> Result<(String, ChangeDescription)> {
485    if !matches!(value, Value::List(_) | Value::Map(_)) {
486        return Err(VaultdbError::InvalidFrontmatter {
487            file: String::new(),
488            reason: format!(
489                "set_field_block called with a scalar value for '{}'; use set_field instead",
490                key
491            ),
492        });
493    }
494
495    let (fm_lines, body) = split_frontmatter(content)?;
496
497    // Render `{key: value}` as YAML so the key sits at column 0 and the
498    // contents indent below it. Splitting by lines gives us the block we
499    // splice into the frontmatter.
500    let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
501    wrapper.insert(key.to_string(), value.clone());
502    let rendered =
503        serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
504            file: String::new(),
505            reason: format!("rendering '{}' as YAML: {}", key, e),
506        })?;
507    let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
508    let new_value_summary = serde_yaml::to_string(value)
509        .map(|s| s.trim_end().to_string())
510        .unwrap_or_default();
511
512    if let Some(key_idx) = find_key_line(&fm_lines, key) {
513        if is_flow_style_list(fm_lines[key_idx]) {
514            return Err(VaultdbError::InvalidFrontmatter {
515                file: String::new(),
516                reason: format!(
517                    "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
518                    key
519                ),
520            });
521        }
522
523        if is_multiline_scalar(fm_lines[key_idx]) {
524            return Err(VaultdbError::InvalidFrontmatter {
525                file: String::new(),
526                reason: format!(
527                    "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
528                    key
529                ),
530            });
531        }
532
533        let extent = field_extent(&fm_lines, key_idx);
534        // Old value summary: either the inline scalar (extent == 1) or the
535        // joined block (extent > 1). Used for ChangeDescription only.
536        let old_value = if extent == 1 {
537            fm_lines[key_idx]
538                .find(':')
539                .map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
540                .unwrap_or_default()
541        } else {
542            fm_lines[key_idx..key_idx + extent].join("\n")
543        };
544
545        let mut result_lines: Vec<String> = Vec::new();
546        for line in &fm_lines[..key_idx] {
547            result_lines.push((*line).to_string());
548        }
549        result_lines.extend(new_lines.iter().cloned());
550        for line in &fm_lines[key_idx + extent..] {
551            result_lines.push((*line).to_string());
552        }
553
554        let change = ChangeDescription::SetField {
555            field: key.to_string(),
556            old_value,
557            new_value: new_value_summary,
558        };
559
560        Ok((reassemble(&result_lines, body, content), change))
561    } else {
562        // Key doesn't exist — insert the block before the closing ---.
563        let mut result_lines: Vec<String> = Vec::new();
564        for (i, line) in fm_lines.iter().enumerate() {
565            if i == fm_lines.len() - 1 && line.trim() == "---" {
566                result_lines.extend(new_lines.iter().cloned());
567            }
568            result_lines.push((*line).to_string());
569        }
570
571        let change = ChangeDescription::SetField {
572            field: key.to_string(),
573            old_value: String::new(),
574            new_value: new_value_summary,
575        };
576
577        Ok((reassemble(&result_lines, body, content), change))
578    }
579}
580
581/// Remove a field entirely from the frontmatter.
582pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
583    let (fm_lines, body) = split_frontmatter(content)?;
584
585    let key_idx =
586        find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
587            file: String::new(),
588            reason: format!("field '{}' not found", key),
589        })?;
590
591    let extent = field_extent(&fm_lines, key_idx);
592    let old_value = fm_lines[key_idx]
593        .find(':')
594        .map(|pos| fm_lines[key_idx][pos + 1..].trim())
595        .unwrap_or("")
596        .to_string();
597
598    let mut result_lines: Vec<String> = Vec::new();
599    for (i, line) in fm_lines.iter().enumerate() {
600        if i >= key_idx && i < key_idx + extent {
601            continue; // skip this field's lines
602        }
603        result_lines.push(line.to_string());
604    }
605
606    let change = ChangeDescription::UnsetField {
607        field: key.to_string(),
608        old_value,
609    };
610
611    Ok((reassemble(&result_lines, body, content), change))
612}
613
614/// Add a tag to the tags list.
615pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
616    let (fm_lines, body) = split_frontmatter(content)?;
617
618    let key_idx =
619        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
620            file: String::new(),
621            reason: "no 'tags' field found".into(),
622        })?;
623
624    if is_flow_style_list(fm_lines[key_idx]) {
625        return Err(VaultdbError::InvalidFrontmatter {
626            file: String::new(),
627            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
628        });
629    }
630
631    let indent_prefix = detect_list_indent(&fm_lines, key_idx);
632    let extent = field_extent(&fm_lines, key_idx);
633    let insert_after = key_idx + extent - 1; // last line of the tags section
634
635    let new_tag_line = format!("{}{}", indent_prefix, tag);
636
637    let mut result_lines: Vec<String> = Vec::new();
638    for (i, line) in fm_lines.iter().enumerate() {
639        result_lines.push(line.to_string());
640        if i == insert_after {
641            result_lines.push(new_tag_line.clone());
642        }
643    }
644
645    let change = ChangeDescription::AddTag {
646        tag: tag.to_string(),
647    };
648
649    Ok((reassemble(&result_lines, body, content), change))
650}
651
652/// Remove a tag from the tags list.
653pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
654    let (fm_lines, body) = split_frontmatter(content)?;
655
656    let key_idx =
657        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
658            file: String::new(),
659            reason: "no 'tags' field found".into(),
660        })?;
661
662    if is_flow_style_list(fm_lines[key_idx]) {
663        return Err(VaultdbError::InvalidFrontmatter {
664            file: String::new(),
665            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
666        });
667    }
668
669    let extent = field_extent(&fm_lines, key_idx);
670
671    // Find the tag line within the tags section
672    let tag_line_idx = fm_lines
673        .iter()
674        .enumerate()
675        .skip(key_idx + 1)
676        .take(extent.saturating_sub(1))
677        .find_map(|(i, line)| {
678            let trimmed = line.trim();
679            let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
680            (tag_value == tag).then_some(i)
681        });
682
683    let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
684        file: String::new(),
685        reason: format!("tag '{}' not found in tags list", tag),
686    })?;
687
688    let mut result_lines: Vec<String> = Vec::new();
689    for (i, line) in fm_lines.iter().enumerate() {
690        if i == tag_line_idx {
691            continue;
692        }
693        result_lines.push(line.to_string());
694    }
695
696    let change = ChangeDescription::RemoveTag {
697        tag: tag.to_string(),
698    };
699
700    Ok((reassemble(&result_lines, body, content), change))
701}
702
703// ── Body mutations ─────────────────────────────────────────────────────────
704//
705// `set_body`, `append_body`, and `clear_body` operate on the body
706// region — everything after the closing `---` of the frontmatter.
707// They preserve the frontmatter byte-for-byte and the file's
708// line-ending style. Bare files (no frontmatter delimiters) get an
709// empty frontmatter synthesized, matching `set_field`'s behaviour;
710// note that for `set_body` this means the file's pre-existing
711// non-frontmatter content is treated as the body and will be
712// replaced. In practice the public mutation API only reaches files
713// that already parsed as records (i.e. had valid frontmatter), so
714// the synthesise path is mostly exercised by tests.
715
716/// Replace the body with `new_body`. Frontmatter is preserved byte-for-byte.
717///
718/// `new_body` is written verbatim — no trailing newline auto-append, no
719/// leading whitespace stripping. Callers that want a trailing newline
720/// should include it in `new_body`.
721pub fn set_body(content: &str, new_body: &str) -> Result<(String, ChangeDescription)> {
722    let (fm_lines, old_body) = split_frontmatter(content)?;
723    let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
724    let change = ChangeDescription::SetBody {
725        old_len: old_body.len(),
726        new_len: new_body.len(),
727    };
728    Ok((reassemble(&fm_owned, new_body, content), change))
729}
730
731/// Clear the body entirely. Equivalent to [`set_body`] with `""` but
732/// surfaces a distinct [`ChangeDescription::ClearBody`] so audit logs
733/// and dry-run reports can call out the destructive intent.
734pub fn clear_body(content: &str) -> Result<(String, ChangeDescription)> {
735    let (fm_lines, old_body) = split_frontmatter(content)?;
736    let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
737    let change = ChangeDescription::ClearBody {
738        old_len: old_body.len(),
739    };
740    Ok((reassemble(&fm_owned, "", content), change))
741}
742
743/// Append `text` to the end of the body, joined by `separator`.
744///
745/// `separator` is inserted only when the existing body is non-empty.
746/// When the existing body ends with the same trailing newline(s) as
747/// `separator`, those trailing newlines are trimmed before joining so
748/// the result doesn't accumulate stacked blank lines on repeated
749/// appends. Default callers use `"\n"`, which yields one newline
750/// between old and new content regardless of the original body's
751/// trailing-newline state.
752pub fn append_body(
753    content: &str,
754    text: &str,
755    separator: &str,
756) -> Result<(String, ChangeDescription)> {
757    let (fm_lines, old_body) = split_frontmatter(content)?;
758    let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
759
760    let new_body = if old_body.is_empty() {
761        text.to_string()
762    } else {
763        // Strip any trailing `\n` / `\r\n` runs from the existing body so
764        // we don't end up with a blank line between old and new content
765        // when the file already ends with a newline (the common case).
766        let trimmed = old_body.trim_end_matches(['\n', '\r']);
767        format!("{}{}{}", trimmed, separator, text)
768    };
769
770    let change = ChangeDescription::AppendBody {
771        added_len: text.len(),
772    };
773    Ok((reassemble(&fm_owned, &new_body, content), change))
774}
775
776/// Reassemble a file from frontmatter lines and body, preserving the original line ending style.
777fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
778    let line_ending = if original.contains("\r\n") {
779        "\r\n"
780    } else {
781        "\n"
782    };
783
784    let mut result = fm_lines.join(line_ending);
785    result.push_str(line_ending);
786    result.push_str(body);
787    result
788}
789
790/// Options controlling how a write touches the filesystem.
791///
792/// Default values match the previous (pre-Phase-A) `std::fs::write`
793/// behaviour: atomic at the rename, but not durable against power loss.
794/// Set `fsync: true` to force the data to stable storage before the
795/// write returns.
796///
797/// Designed to be `Copy + Default + serde::*` so it can be piped through
798/// the mutation builders, configured from env vars or config files, or
799/// surfaced over a Tauri command.
800#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
801pub struct WriteOptions {
802    /// fsync the temp file's data, then fsync the parent directory's
803    /// metadata, before considering the write complete. Adds one or two
804    /// disk-flush IOs per write — typically 1–10ms on consumer SSDs and
805    /// 10–50ms on spinning disks. Required for durable mutations (e.g. a
806    /// long-lived Tauri app) that need to survive sudden power loss with
807    /// the change preserved.
808    pub fsync: bool,
809}
810
811impl WriteOptions {
812    /// Convenience: opts with `fsync` set to true.
813    pub fn durable() -> Self {
814        Self { fsync: true }
815    }
816}
817
818/// fsync a directory so its dirent updates (renames, creates, removes)
819/// are durable. Best-effort on Windows: opening a directory for sync
820/// is supported on NTFS but not all filesystems.
821pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
822    let f = std::fs::File::open(dir)?;
823    f.sync_all()
824}
825
826/// Atomically replace the contents of `path` with `content` using the
827/// default [`WriteOptions`] (no fsync). See [`atomic_write_with`] for the
828/// version that takes options.
829pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
830    atomic_write_with(path, content, WriteOptions::default())
831}
832
833/// Atomically create a new file at `path` with `content`. **Refuses to
834/// overwrite** if `path` already exists — returns
835/// `io::ErrorKind::AlreadyExists`. Used by `CreateBuilder::execute` as
836/// defence-in-depth against a TOCTOU window between its `dest.exists()`
837/// check and the rename: even if an external process slips a file into
838/// the destination after the check, this won't clobber it.
839///
840/// Same atomic tempfile+rename pattern as [`atomic_write_with`]; the
841/// only difference is `persist_noclobber` in place of `persist`, which
842/// maps to `link(2)` on POSIX and `MoveFileEx` without
843/// `MOVEFILE_REPLACE_EXISTING` on Windows.
844pub fn atomic_create_with(
845    path: &std::path::Path,
846    content: &str,
847    opts: WriteOptions,
848) -> std::io::Result<()> {
849    let dir = path.parent().ok_or_else(|| {
850        std::io::Error::other(format!(
851            "atomic_create target has no parent dir: {}",
852            path.display()
853        ))
854    })?;
855
856    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
857    use std::io::Write;
858    tmp.write_all(content.as_bytes())?;
859    tmp.flush()?;
860
861    if opts.fsync {
862        tmp.as_file().sync_all()?;
863    }
864
865    tmp.persist_noclobber(path).map_err(|e| e.error)?;
866
867    if opts.fsync {
868        fsync_dir(dir)?;
869    }
870    Ok(())
871}
872
873/// Atomically replace the contents of `path` with `content`, honoring
874/// [`WriteOptions`].
875///
876/// Writes to a temp file in the same directory, optionally fsyncs the
877/// temp file's data, then renames over the target. The rename is atomic
878/// on POSIX same-filesystem operations and on Windows with
879/// `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`. Concurrent readers either
880/// see the full old content or the full new content; they never see a
881/// partial write.
882///
883/// When `opts.fsync` is true, the temp file is fsynced before rename
884/// AND the parent directory is fsynced after rename, so the change
885/// survives power loss the moment this function returns Ok.
886pub fn atomic_write_with(
887    path: &std::path::Path,
888    content: &str,
889    opts: WriteOptions,
890) -> std::io::Result<()> {
891    let dir = path.parent().ok_or_else(|| {
892        std::io::Error::other(format!(
893            "atomic_write target has no parent dir: {}",
894            path.display()
895        ))
896    })?;
897
898    // tempfile::NamedTempFile creates a uniquely-named file in `dir`,
899    // which guarantees same-filesystem rename below. The file is
900    // cleaned up automatically on drop if `persist` isn't called (e.g.
901    // if the write fails mid-way).
902    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
903
904    use std::io::Write;
905    tmp.write_all(content.as_bytes())?;
906    tmp.flush()?;
907
908    // Optional data fsync before the rename. The order matters: if we
909    // rename first and then fsync, a power loss between the rename and
910    // the fsync can leave the rename visible but pointing at undefined
911    // data. POSIX guarantees that data fsynced before the rename is
912    // durable as soon as the rename's directory entry is durable.
913    if opts.fsync {
914        tmp.as_file().sync_all()?;
915    }
916
917    // `persist` does the atomic rename. On error it returns the temp
918    // file plus the io::Error; we discard the temp file (it'll be
919    // cleaned up by Drop) and propagate just the error.
920    tmp.persist(path).map_err(|e| e.error)?;
921
922    if opts.fsync {
923        fsync_dir(dir)?;
924    }
925    Ok(())
926}
927
928/// Write a WriteResult to disk atomically with default options.
929pub fn apply(result: &WriteResult) -> std::io::Result<()> {
930    apply_with(result, WriteOptions::default())
931}
932
933/// Write a WriteResult to disk atomically, honoring [`WriteOptions`].
934pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
935    atomic_write_with(&result.path, &result.modified_content, opts)
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    const MOVIE_FILE: &str = "\
943---
944aliases:
945tags:
946  - type/leaf
947  - topic/movies
948  - source/video
949  - genre/drama
950status: to-watch
951rating:
952director: Sam Mendes
953year: 2019
954related-to:
955---
956
957Part of [[Watchlist]]
958";
959
960    const CHINESE_FILE: &str = "\
961---
962aliases:
963- kuài
964tags:
965- type/concept
966- topic/chinese
967- source/self-study
968pinyin: kuài
969anlam: hızlı
970tür: sifat
971hsk: 1
972kaliplar:
973- kalip: 快乐
974  pinyin: kuàilè
975  anlam: mutlu, neşeli
976ornekler:
977- cumle: 他跑得很快。
978  pinyin: Tā pǎo de hěn kuài.
979  anlam: O çok hızlı koşuyor.
980related-to:
981---
982
983# 快 (kuài) — hızlı
984
985Body text.
986";
987
988    #[test]
989    fn set_existing_scalar_field() {
990        let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
991        assert!(result.contains("status: watched"));
992        assert!(!result.contains("to-watch"));
993        // Body preserved
994        assert!(result.contains("Part of [[Watchlist]]"));
995        match change {
996            ChangeDescription::SetField {
997                field,
998                old_value,
999                new_value,
1000            } => {
1001                assert_eq!(field, "status");
1002                assert_eq!(old_value, "to-watch");
1003                assert_eq!(new_value, "watched");
1004            }
1005            _ => panic!("expected SetField"),
1006        }
1007    }
1008
1009    #[test]
1010    fn set_null_field() {
1011        // 1.3.1: `yaml_quote_value` now quotes type-ambiguous bare
1012        // scalars (booleans, null, numbers) so a `Value::String("8")`
1013        // round-trips as the string "8" rather than the integer 8.
1014        // For typed callers (UpdateBuilder via `Value::Integer(8)` →
1015        // `render_value_for_yaml` → "8" → `set_field_preformatted`)
1016        // an integer still lands as `rating: 8`. This test exercises
1017        // the raw `set_field` path directly with a string value, so
1018        // the quoted form is correct.
1019        let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
1020        assert!(result.contains("rating: '8'"), "got:\n{}", result);
1021    }
1022
1023    #[test]
1024    fn set_new_field() {
1025        let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
1026        assert!(result.contains("language: English"));
1027        // Should be inserted before closing ---
1028        let closing_idx = result.rfind("\n---\n").unwrap();
1029        let lang_idx = result.find("language: English").unwrap();
1030        assert!(lang_idx < closing_idx);
1031    }
1032
1033    #[test]
1034    fn set_scalar_over_block_field_replaces() {
1035        // A scalar set over a block-style list/map now REPLACES the whole
1036        // field span (it used to be refused as a "complex type"). Flow-style
1037        // and multiline scalars are still refused — see the dedicated tests.
1038        let (result, change) = set_field(CHINESE_FILE, "kaliplar", "something").unwrap();
1039        assert!(result.contains("kaliplar: something"), "got:\n{}", result);
1040        assert!(!result.contains("快乐")); // old block item gone
1041        assert!(!result.contains("kuàilè"));
1042        // Neighbouring fields on both sides of the replaced span survive.
1043        assert!(result.contains("hsk: 1"));
1044        assert!(result.contains("ornekler:"));
1045        assert!(result.contains("Body text."));
1046        match change {
1047            ChangeDescription::SetField {
1048                field, new_value, ..
1049            } => {
1050                assert_eq!(field, "kaliplar");
1051                assert_eq!(new_value, "something");
1052            }
1053            _ => panic!("expected SetField"),
1054        }
1055    }
1056
1057    #[test]
1058    fn set_field_initializes_frontmatter_on_bare_file() {
1059        // A file with no frontmatter block at all gets one synthesized so the
1060        // field can be added, rather than the write being refused.
1061        let bare = "# Just a heading\n\nSome body text.\n";
1062        let (result, _) = set_field(bare, "db-table", "rusen-wiki").unwrap();
1063        assert!(result.starts_with("---\n"), "got:\n{}", result);
1064        assert!(result.contains("db-table: rusen-wiki"));
1065        // Body preserved after the synthesized frontmatter.
1066        assert!(result.contains("# Just a heading"));
1067        assert!(result.contains("Some body text."));
1068        // Frontmatter re-parses and carries the new field.
1069        let fm_end = result[4..].find("\n---\n").unwrap() + 4;
1070        let fm = &result[4..fm_end];
1071        let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
1072        assert_eq!(
1073            parsed
1074                .as_mapping()
1075                .and_then(|m| m.get("db-table"))
1076                .and_then(|v| v.as_str()),
1077            Some("rusen-wiki")
1078        );
1079    }
1080
1081    #[test]
1082    fn set_value_needing_quotes() {
1083        let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
1084        assert!(result.contains("note: 'key: value'"));
1085    }
1086
1087    // ── set_field_block (typed list/map writes) ──────────────────────────
1088
1089    #[test]
1090    fn set_field_block_inserts_new_list_as_block_yaml() {
1091        let value = Value::List(vec![Value::String("kedi".into())]);
1092        let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
1093        // Block-style: a `key:` line followed by `- item` lines, NOT
1094        // `anlamlar: '- kedi'` (the pre-fix quoted-scalar shape).
1095        assert!(result.contains("anlamlar:\n- kedi"));
1096        assert!(!result.contains("anlamlar: '- kedi'"));
1097        // Inserted before closing `---`.
1098        let closing_idx = result.rfind("\n---\n").unwrap();
1099        assert!(result.find("anlamlar:").unwrap() < closing_idx);
1100        match change {
1101            ChangeDescription::SetField {
1102                field, new_value, ..
1103            } => {
1104                assert_eq!(field, "anlamlar");
1105                assert_eq!(new_value.trim_end(), "- kedi");
1106            }
1107            _ => panic!("expected SetField"),
1108        }
1109    }
1110
1111    #[test]
1112    fn set_field_block_multi_item_list_round_trips() {
1113        let value = Value::List(vec![
1114            Value::String("猫が好きです。".into()),
1115            Value::String("私の猫は黒いです。".into()),
1116        ]);
1117        let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
1118        assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
1119        // Parse the result back and confirm the field is a list, not a string.
1120        let fm_end = result[4..].find("\n---\n").unwrap() + 4;
1121        let fm = &result[4..fm_end];
1122        let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
1123        let items = parsed
1124            .as_mapping()
1125            .and_then(|m| m.get("ornekler_jp"))
1126            .and_then(|v| v.as_sequence())
1127            .expect("ornekler_jp must round-trip as a YAML sequence");
1128        assert_eq!(items.len(), 2);
1129    }
1130
1131    #[test]
1132    fn set_field_block_replaces_existing_block_list() {
1133        // CHINESE_FILE has `kaliplar` as a block-style list of maps. Replace
1134        // it with a fresh list and confirm the old span is gone.
1135        let value = Value::List(vec![Value::String("replaced".into())]);
1136        let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
1137        assert!(result.contains("kaliplar:\n- replaced"));
1138        assert!(!result.contains("快乐")); // old item gone
1139        assert!(!result.contains("kuàilè")); // old nested key gone
1140        // Adjacent fields preserved.
1141        assert!(result.contains("hsk: 1"));
1142        assert!(result.contains("ornekler:"));
1143    }
1144
1145    #[test]
1146    fn set_field_block_writes_map_as_nested_yaml() {
1147        let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
1148        m.insert("k1".into(), Value::String("v1".into()));
1149        m.insert("k2".into(), Value::Integer(2));
1150        let value = Value::Map(m);
1151        let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
1152        assert!(result.contains("meta:\n  k1: v1\n  k2: 2"));
1153    }
1154
1155    #[test]
1156    fn set_field_block_rejects_flow_style_existing() {
1157        let content = "---\ntags: [a, b]\n---\nbody\n";
1158        let value = Value::List(vec![Value::String("c".into())]);
1159        let err = set_field_block(content, "tags", &value).unwrap_err();
1160        let msg = format!("{}", err);
1161        assert!(msg.contains("flow-style"), "got: {}", msg);
1162    }
1163
1164    #[test]
1165    fn set_field_block_rejects_scalar_value() {
1166        // Programmer error guard: scalars must go through set_field, not here.
1167        let err =
1168            set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
1169        let msg = format!("{}", err);
1170        assert!(msg.contains("scalar value"), "got: {}", msg);
1171    }
1172
1173    #[test]
1174    fn unset_scalar_field() {
1175        let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
1176        assert!(!result.contains("director:"));
1177        // Other fields preserved
1178        assert!(result.contains("status: to-watch"));
1179        assert!(result.contains("year: 2019"));
1180        assert!(result.contains("Part of [[Watchlist]]"));
1181    }
1182
1183    #[test]
1184    fn unset_list_field() {
1185        let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
1186        assert!(!result.contains("kaliplar:"));
1187        assert!(!result.contains("快乐"));
1188        // Other fields preserved
1189        assert!(result.contains("pinyin: kuài"));
1190        assert!(result.contains("Body text."));
1191    }
1192
1193    #[test]
1194    fn unset_nonexistent_field() {
1195        let result = unset_field(MOVIE_FILE, "nonexistent");
1196        assert!(result.is_err());
1197    }
1198
1199    #[test]
1200    fn add_tag_2space_indent() {
1201        let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
1202        assert!(result.contains("  - genre/war"));
1203        // Existing tags still present
1204        assert!(result.contains("  - type/leaf"));
1205        assert!(result.contains("  - genre/drama"));
1206    }
1207
1208    #[test]
1209    fn add_tag_0indent() {
1210        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1211        assert!(result.contains("- topic/hsk1"));
1212        // Existing tags preserved
1213        assert!(result.contains("- type/concept"));
1214        assert!(result.contains("- topic/chinese"));
1215    }
1216
1217    #[test]
1218    fn remove_tag_2space_indent() {
1219        let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
1220        assert!(!result.contains("genre/drama"));
1221        // Other tags preserved
1222        assert!(result.contains("  - type/leaf"));
1223        assert!(result.contains("  - source/video"));
1224    }
1225
1226    #[test]
1227    fn remove_tag_0indent() {
1228        let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
1229        assert!(!result.contains("topic/chinese"));
1230        assert!(result.contains("- type/concept"));
1231        assert!(result.contains("- source/self-study"));
1232    }
1233
1234    #[test]
1235    fn remove_nonexistent_tag() {
1236        let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
1237        assert!(result.is_err());
1238    }
1239
1240    #[test]
1241    fn body_preserved_after_set() {
1242        let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
1243        assert!(result.ends_with("Part of [[Watchlist]]\n"));
1244    }
1245
1246    #[test]
1247    fn body_preserved_after_unset() {
1248        let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
1249        assert!(result.contains("# 快 (kuài) — hızlı"));
1250        assert!(result.contains("Body text."));
1251    }
1252
1253    #[test]
1254    fn body_preserved_after_add_tag() {
1255        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1256        assert!(result.contains("# 快 (kuài) — hızlı"));
1257    }
1258
1259    #[test]
1260    fn chinese_content_preserved() {
1261        let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
1262        assert!(result.contains("pinyin: kuài"));
1263        assert!(result.contains("anlam: hızlı"));
1264        assert!(result.contains("tür: sifat"));
1265        assert!(result.contains("kalip: 快乐"));
1266        assert!(result.contains("cumle: 他跑得很快。"));
1267    }
1268
1269    // ── Safety checks ─────────────────────────
1270
1271    #[test]
1272    fn set_field_rejects_flow_style() {
1273        let content = "---\ntags: [a, b, c]\n---\nBody.\n";
1274        let result = set_field(content, "tags", "x");
1275        assert!(result.is_err());
1276        let err = result.unwrap_err().to_string();
1277        assert!(err.contains("flow-style"));
1278    }
1279
1280    #[test]
1281    fn set_field_rejects_multiline_scalar() {
1282        let content = "---\ndescription: |\n  Multi line\n  content here\n---\nBody.\n";
1283        let result = set_field(content, "description", "new value");
1284        assert!(result.is_err());
1285        let err = result.unwrap_err().to_string();
1286        assert!(err.contains("multiline"));
1287    }
1288
1289    #[test]
1290    fn add_tag_rejects_flow_style() {
1291        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1292        let result = add_tag(content, "topic/new");
1293        assert!(result.is_err());
1294        let err = result.unwrap_err().to_string();
1295        assert!(err.contains("flow-style"));
1296    }
1297
1298    #[test]
1299    fn remove_tag_rejects_flow_style() {
1300        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1301        let result = remove_tag(content, "topic/ai");
1302        assert!(result.is_err());
1303        let err = result.unwrap_err().to_string();
1304        assert!(err.contains("flow-style"));
1305    }
1306
1307    #[test]
1308    fn atomic_create_refuses_to_overwrite_existing_file() {
1309        // Defence-in-depth: even if a CreateBuilder's compute-time
1310        // `dest.exists()` check passed (or was bypassed), the
1311        // create-with-no-clobber write must still refuse to silently
1312        // overwrite a file that appeared in the gap.
1313        use std::fs;
1314        let dir = tempfile::TempDir::new().unwrap();
1315        let target = dir.path().join("note.md");
1316        fs::write(&target, "existing content\n").unwrap();
1317
1318        let err = atomic_create_with(&target, "would clobber\n", WriteOptions::default())
1319            .expect_err("atomic_create must refuse to overwrite");
1320        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1321
1322        // Original file content is intact.
1323        assert_eq!(fs::read_to_string(&target).unwrap(), "existing content\n");
1324    }
1325
1326    #[test]
1327    fn atomic_create_writes_to_new_path() {
1328        use std::fs;
1329        let dir = tempfile::TempDir::new().unwrap();
1330        let target = dir.path().join("fresh.md");
1331        atomic_create_with(&target, "hello\n", WriteOptions::default()).unwrap();
1332        assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
1333    }
1334
1335    #[test]
1336    fn set_field_preformatted_writes_value_verbatim() {
1337        // `set_field_preformatted` must NOT call yaml_quote_value on its
1338        // input — it's the caller's contract that the value is already
1339        // a valid YAML scalar. Pre-fix, an already-single-quoted URL
1340        // was re-wrapped in double quotes by `set_field`.
1341        let content = "---\nurl:\n---\nBody\n";
1342        let preformatted = "'https://www.amazon.com.tr/foo'";
1343        let (out, _) = set_field_preformatted(content, "url", preformatted).unwrap();
1344        assert!(
1345            out.contains("url: 'https://www.amazon.com.tr/foo'"),
1346            "got:\n{}",
1347            out
1348        );
1349        assert!(
1350            !out.contains("url: \"'"),
1351            "preformatted value was double-quoted; got:\n{}",
1352            out
1353        );
1354    }
1355
1356    // ── Body mutations ───────────────────────────────────────────────────
1357
1358    #[test]
1359    fn set_body_replaces_existing_body() {
1360        let (result, change) = set_body(MOVIE_FILE, "New body content.\n").unwrap();
1361        // Frontmatter intact.
1362        assert!(result.contains("director: Sam Mendes"));
1363        assert!(result.contains("status: to-watch"));
1364        // Old body gone, new body present.
1365        assert!(!result.contains("Part of [[Watchlist]]"));
1366        assert!(result.contains("New body content."));
1367        // Body sits immediately after the closing `---\n`.
1368        assert!(result.ends_with("---\nNew body content.\n"));
1369        match change {
1370            ChangeDescription::SetBody { new_len, .. } => assert_eq!(new_len, 18),
1371            other => panic!("expected SetBody, got {:?}", other),
1372        }
1373    }
1374
1375    #[test]
1376    fn set_body_writes_verbatim_no_trailing_newline_added() {
1377        // Caller controls the trailing-newline behaviour. If they pass a
1378        // body without `\n`, the file ends without one.
1379        let (result, _) = set_body(MOVIE_FILE, "no newline").unwrap();
1380        assert!(result.ends_with("---\nno newline"));
1381    }
1382
1383    #[test]
1384    fn set_body_on_frontmatter_only_file() {
1385        // File with frontmatter and empty body — set_body fills it in.
1386        let fm_only = "---\nstatus: x\n---\n";
1387        let (result, _) = set_body(fm_only, "Hello.\n").unwrap();
1388        assert_eq!(result, "---\nstatus: x\n---\nHello.\n");
1389    }
1390
1391    #[test]
1392    fn set_body_on_bare_file_synthesizes_frontmatter() {
1393        // No frontmatter delimiters at all — split_frontmatter synthesizes
1394        // empty frontmatter and treats the original content as body.
1395        // set_body then replaces that body.
1396        let bare = "Just a bare note.\n";
1397        let (result, change) = set_body(bare, "Replaced.\n").unwrap();
1398        assert!(result.starts_with("---\n---\n"));
1399        assert!(result.ends_with("Replaced.\n"));
1400        assert!(!result.contains("Just a bare note"));
1401        match change {
1402            ChangeDescription::SetBody { old_len, new_len } => {
1403                assert_eq!(old_len, bare.len());
1404                assert_eq!(new_len, "Replaced.\n".len());
1405            }
1406            other => panic!("expected SetBody, got {:?}", other),
1407        }
1408    }
1409
1410    #[test]
1411    fn clear_body_keeps_frontmatter_and_drops_body() {
1412        let (result, change) = clear_body(MOVIE_FILE).unwrap();
1413        assert!(result.contains("director: Sam Mendes"));
1414        assert!(!result.contains("Part of [[Watchlist]]"));
1415        // Body region is empty: ends with the closing `---\n`.
1416        assert!(result.ends_with("---\n"));
1417        match change {
1418            ChangeDescription::ClearBody { old_len } => assert!(old_len > 0),
1419            other => panic!("expected ClearBody, got {:?}", other),
1420        }
1421    }
1422
1423    #[test]
1424    fn append_body_on_existing_body_uses_separator() {
1425        // MOVIE_FILE body is "\nPart of [[Watchlist]]\n" — note the leading
1426        // blank line is part of the body. Appending "next" with "\n"
1427        // separator strips the trailing newline from the existing body,
1428        // then joins.
1429        let (result, change) = append_body(MOVIE_FILE, "Next line.", "\n").unwrap();
1430        assert!(result.contains("Part of [[Watchlist]]"));
1431        assert!(result.ends_with("Part of [[Watchlist]]\nNext line."));
1432        match change {
1433            ChangeDescription::AppendBody { added_len } => assert_eq!(added_len, 10),
1434            other => panic!("expected AppendBody, got {:?}", other),
1435        }
1436    }
1437
1438    #[test]
1439    fn append_body_with_custom_separator() {
1440        // Pass a blank-line separator. Result should have one blank line
1441        // between old and new content.
1442        let fm_with_body = "---\nstatus: x\n---\nFirst.\n";
1443        let (result, _) = append_body(fm_with_body, "Second.", "\n\n").unwrap();
1444        assert!(result.ends_with("First.\n\nSecond."));
1445    }
1446
1447    #[test]
1448    fn append_body_on_empty_body_skips_separator() {
1449        // Empty body → no separator added; appended text becomes the body.
1450        let fm_only = "---\nstatus: x\n---\n";
1451        let (result, _) = append_body(fm_only, "First line.", "\n").unwrap();
1452        assert_eq!(result, "---\nstatus: x\n---\nFirst line.");
1453    }
1454
1455    #[test]
1456    fn append_body_idempotent_against_trailing_newlines() {
1457        // Repeatedly appending with "\n" separator should not accumulate
1458        // blank lines, even if each append leaves a trailing newline.
1459        let start = "---\n---\n";
1460        let (r1, _) = append_body(start, "a\n", "\n").unwrap();
1461        let (r2, _) = append_body(&r1, "b\n", "\n").unwrap();
1462        let (r3, _) = append_body(&r2, "c\n", "\n").unwrap();
1463        assert_eq!(r3, "---\n---\na\nb\nc\n");
1464    }
1465
1466    #[test]
1467    fn append_body_on_bare_file_appends_after_original() {
1468        // Bare file — synthesized fm, body is the original content.
1469        // Append adds to the end of that content.
1470        let bare = "Existing.\n";
1471        let (result, _) = append_body(bare, "More.", "\n").unwrap();
1472        assert!(result.starts_with("---\n---\n"));
1473        assert!(result.ends_with("Existing.\nMore."));
1474    }
1475
1476    #[test]
1477    fn set_field_still_quotes_raw_values() {
1478        // Sanity: the public `set_field` is unchanged — raw strings
1479        // with special characters still get quoted exactly once.
1480        let content = "---\nurl:\n---\n";
1481        let (out, _) = set_field(content, "url", "https://www.example.com").unwrap();
1482        assert!(
1483            out.contains("url: 'https://www.example.com'"),
1484            "got:\n{}",
1485            out
1486        );
1487        // No double-quoted wrapping.
1488        assert!(!out.contains("url: \"'"), "got:\n{}", out);
1489    }
1490}