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};
7
8/// Describes a single change made to a file.
9#[derive(Debug)]
10pub enum ChangeDescription {
11    SetField {
12        field: String,
13        old_value: String,
14        new_value: String,
15    },
16    UnsetField {
17        field: String,
18        old_value: String,
19    },
20    AddTag {
21        tag: String,
22    },
23    RemoveTag {
24        tag: String,
25    },
26}
27
28impl std::fmt::Display for ChangeDescription {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            ChangeDescription::SetField {
32                field,
33                old_value,
34                new_value,
35            } => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
36            ChangeDescription::UnsetField { field, old_value } => {
37                write!(f, "unset {} (was: {})", field, old_value)
38            }
39            ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
40            ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
41        }
42    }
43}
44
45/// Result of a write operation on a single file.
46pub struct WriteResult {
47    pub path: std::path::PathBuf,
48    pub original_content: String,
49    pub modified_content: String,
50    pub changes: Vec<ChangeDescription>,
51}
52
53/// Split file content into frontmatter lines and body.
54/// Returns (frontmatter_lines_including_delimiters, body_str).
55fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
56    let lines: Vec<&str> = content.lines().collect();
57
58    if lines.is_empty() || lines[0].trim() != "---" {
59        return Err(VaultdbError::NoFrontmatter("content".into()));
60    }
61
62    // Find closing ---
63    let close_idx = lines[1..]
64        .iter()
65        .position(|l| l.trim() == "---")
66        .map(|i| i + 1); // offset by 1 because we started from lines[1..]
67
68    match close_idx {
69        Some(idx) => {
70            let fm_lines = &lines[..=idx];
71            // Body starts after the closing --- line
72            // We need to find the byte offset of the body
73            let mut byte_offset = 0;
74            for (i, line) in content.lines().enumerate() {
75                byte_offset += line.len();
76                // Account for the newline character
77                if byte_offset < content.len() {
78                    if content.as_bytes().get(byte_offset) == Some(&b'\r') {
79                        byte_offset += 1; // \r
80                    }
81                    if byte_offset < content.len() {
82                        byte_offset += 1; // \n
83                    }
84                }
85                if i == idx {
86                    break;
87                }
88            }
89            let body = &content[byte_offset..];
90            Ok((fm_lines.to_vec(), body))
91        }
92        None => Err(VaultdbError::NoFrontmatter("content".into())),
93    }
94}
95
96/// Detect the indentation used for list items under a key.
97/// Returns the prefix string (e.g., "  - " or "- ").
98fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
99    // Look at the line after the key line
100    for line in fm_lines.iter().skip(key_line_idx + 1) {
101        let trimmed = line.trim();
102
103        // Stop if we hit another top-level key or delimiter
104        if trimmed == "---"
105            || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
106        {
107            break;
108        }
109
110        if trimmed.starts_with("- ") || trimmed == "-" {
111            // Return the actual prefix including whitespace
112            let dash_pos = line.find('-').unwrap();
113            let prefix = &line[..dash_pos];
114            return format!("{}- ", prefix);
115        }
116    }
117    // Default: 2-space indent
118    "  - ".to_string()
119}
120
121/// Find the line index of a top-level key in frontmatter lines (between delimiters).
122fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
123    let patterns = [format!("{}:", key), format!("{} :", key)];
124    for (i, line) in fm_lines.iter().enumerate() {
125        if i == 0 || line.trim() == "---" {
126            continue; // skip delimiters
127        }
128        let trimmed = line.trim_start();
129        for pattern in &patterns {
130            if trimmed.starts_with(pattern) {
131                // Make sure we matched the full key, not a prefix
132                let after = &trimmed[pattern.len()..];
133                if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
134                    return Some(i);
135                }
136            }
137        }
138    }
139    None
140}
141
142/// Determine how many lines a field spans (including nested list/map items).
143fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
144    let key_line = fm_lines[key_line_idx];
145    let key_indent = key_line.len() - key_line.trim_start().len();
146
147    // Check if the key has an inline value (not a list/map)
148    let after_colon = key_line.trim_start();
149    if let Some(colon_pos) = after_colon.find(':') {
150        let value_part = after_colon[colon_pos + 1..].trim();
151        if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
152            // Inline scalar value — single line
153            return 1;
154        }
155    }
156
157    let mut extent = 1;
158    for line in fm_lines.iter().skip(key_line_idx + 1) {
159        let trimmed = line.trim();
160
161        // Stop at closing delimiter
162        if trimmed == "---" {
163            break;
164        }
165
166        // Empty line ends the field
167        if trimmed.is_empty() {
168            break;
169        }
170
171        let line_indent = line.len() - line.trim_start().len();
172
173        // If this line is at the same or lesser indentation and doesn't start with '-',
174        // it's a new top-level key
175        if line_indent <= key_indent && !trimmed.starts_with('-') {
176            break;
177        }
178
179        // Lines starting with '-' at the same indent level are list items of this key
180        if line_indent == key_indent && trimmed.starts_with('-') {
181            extent += 1;
182            continue;
183        }
184
185        // Indented lines are continuations
186        if line_indent > key_indent {
187            extent += 1;
188            continue;
189        }
190
191        break;
192    }
193    extent
194}
195
196/// Check if a field line uses flow-style list syntax: `key: [a, b, c]`
197fn is_flow_style_list(line: &str) -> bool {
198    if let Some(colon_pos) = line.find(':') {
199        let value = line[colon_pos + 1..].trim();
200        value.starts_with('[') && value.ends_with(']')
201    } else {
202        false
203    }
204}
205
206/// Check if a field line uses a multiline scalar indicator: `key: |` or `key: >`
207fn is_multiline_scalar(line: &str) -> bool {
208    if let Some(colon_pos) = line.find(':') {
209        let value = line[colon_pos + 1..].trim();
210        value == "|"
211            || value == ">"
212            || value == "|+"
213            || value == "|-"
214            || value == ">+"
215            || value == ">-"
216    } else {
217        false
218    }
219}
220
221/// Quote a YAML value if it contains special characters.
222pub fn quote_value(value: &str) -> String {
223    yaml_quote_value(value)
224}
225
226fn yaml_quote_value(value: &str) -> String {
227    let needs_quoting = value.contains(':')
228        || value.contains('#')
229        || value.contains('[')
230        || value.contains(']')
231        || value.contains('{')
232        || value.contains('}')
233        || value.contains('\'')
234        || value.contains('"')
235        || value.contains('&')
236        || value.contains('*')
237        || value.contains('!')
238        || value.contains('|')
239        || value.contains('>')
240        || value.contains('%')
241        || value.contains('@')
242        || value.starts_with(' ')
243        || value.ends_with(' ')
244        || value.starts_with('-')
245        || value.starts_with('?');
246
247    if needs_quoting {
248        if value.contains('\'') {
249            format!("\"{}\"", value.replace('"', "\\\""))
250        } else {
251            format!("'{}'", value)
252        }
253    } else {
254        value.to_string()
255    }
256}
257
258/// Set a scalar field to a new value in the frontmatter.
259pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
260    let (fm_lines, body) = split_frontmatter(content)?;
261    let quoted_value = yaml_quote_value(value);
262
263    if let Some(key_idx) = find_key_line(&fm_lines, key) {
264        let extent = field_extent(&fm_lines, key_idx);
265        if extent > 1 {
266            return Err(VaultdbError::InvalidFrontmatter {
267                file: String::new(),
268                reason: format!(
269                    "field '{}' is a complex type (list/map). Use --unset first, then re-add.",
270                    key
271                ),
272            });
273        }
274
275        if is_flow_style_list(fm_lines[key_idx]) {
276            return Err(VaultdbError::InvalidFrontmatter {
277                file: String::new(),
278                reason: format!(
279                    "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
280                    key
281                ),
282            });
283        }
284
285        if is_multiline_scalar(fm_lines[key_idx]) {
286            return Err(VaultdbError::InvalidFrontmatter {
287                file: String::new(),
288                reason: format!(
289                    "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
290                    key
291                ),
292            });
293        }
294
295        let old_line = fm_lines[key_idx];
296        // Extract old value for the change description
297        let old_value = old_line
298            .find(':')
299            .map(|pos| old_line[pos + 1..].trim())
300            .unwrap_or("")
301            .to_string();
302
303        let new_line = format!("{}: {}", key, quoted_value);
304
305        let mut result_lines: Vec<String> = Vec::new();
306        for (i, line) in fm_lines.iter().enumerate() {
307            if i == key_idx {
308                result_lines.push(new_line.clone());
309            } else {
310                result_lines.push(line.to_string());
311            }
312        }
313
314        let change = ChangeDescription::SetField {
315            field: key.to_string(),
316            old_value,
317            new_value: value.to_string(),
318        };
319
320        Ok((reassemble(&result_lines, body, content), change))
321    } else {
322        // Key doesn't exist — insert before closing ---
323        let mut result_lines: Vec<String> = Vec::new();
324        for (i, line) in fm_lines.iter().enumerate() {
325            if i == fm_lines.len() - 1 && line.trim() == "---" {
326                result_lines.push(format!("{}: {}", key, quoted_value));
327            }
328            result_lines.push(line.to_string());
329        }
330
331        let change = ChangeDescription::SetField {
332            field: key.to_string(),
333            old_value: String::new(),
334            new_value: value.to_string(),
335        };
336
337        Ok((reassemble(&result_lines, body, content), change))
338    }
339}
340
341/// Remove a field entirely from the frontmatter.
342pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
343    let (fm_lines, body) = split_frontmatter(content)?;
344
345    let key_idx =
346        find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
347            file: String::new(),
348            reason: format!("field '{}' not found", key),
349        })?;
350
351    let extent = field_extent(&fm_lines, key_idx);
352    let old_value = fm_lines[key_idx]
353        .find(':')
354        .map(|pos| fm_lines[key_idx][pos + 1..].trim())
355        .unwrap_or("")
356        .to_string();
357
358    let mut result_lines: Vec<String> = Vec::new();
359    for (i, line) in fm_lines.iter().enumerate() {
360        if i >= key_idx && i < key_idx + extent {
361            continue; // skip this field's lines
362        }
363        result_lines.push(line.to_string());
364    }
365
366    let change = ChangeDescription::UnsetField {
367        field: key.to_string(),
368        old_value,
369    };
370
371    Ok((reassemble(&result_lines, body, content), change))
372}
373
374/// Add a tag to the tags list.
375pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
376    let (fm_lines, body) = split_frontmatter(content)?;
377
378    let key_idx =
379        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
380            file: String::new(),
381            reason: "no 'tags' field found".into(),
382        })?;
383
384    if is_flow_style_list(fm_lines[key_idx]) {
385        return Err(VaultdbError::InvalidFrontmatter {
386            file: String::new(),
387            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
388        });
389    }
390
391    let indent_prefix = detect_list_indent(&fm_lines, key_idx);
392    let extent = field_extent(&fm_lines, key_idx);
393    let insert_after = key_idx + extent - 1; // last line of the tags section
394
395    let new_tag_line = format!("{}{}", indent_prefix, tag);
396
397    let mut result_lines: Vec<String> = Vec::new();
398    for (i, line) in fm_lines.iter().enumerate() {
399        result_lines.push(line.to_string());
400        if i == insert_after {
401            result_lines.push(new_tag_line.clone());
402        }
403    }
404
405    let change = ChangeDescription::AddTag {
406        tag: tag.to_string(),
407    };
408
409    Ok((reassemble(&result_lines, body, content), change))
410}
411
412/// Remove a tag from the tags list.
413pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
414    let (fm_lines, body) = split_frontmatter(content)?;
415
416    let key_idx =
417        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
418            file: String::new(),
419            reason: "no 'tags' field found".into(),
420        })?;
421
422    if is_flow_style_list(fm_lines[key_idx]) {
423        return Err(VaultdbError::InvalidFrontmatter {
424            file: String::new(),
425            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
426        });
427    }
428
429    let extent = field_extent(&fm_lines, key_idx);
430
431    // Find the tag line within the tags section
432    let tag_line_idx = fm_lines
433        .iter()
434        .enumerate()
435        .skip(key_idx + 1)
436        .take(extent.saturating_sub(1))
437        .find_map(|(i, line)| {
438            let trimmed = line.trim();
439            let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
440            (tag_value == tag).then_some(i)
441        });
442
443    let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
444        file: String::new(),
445        reason: format!("tag '{}' not found in tags list", tag),
446    })?;
447
448    let mut result_lines: Vec<String> = Vec::new();
449    for (i, line) in fm_lines.iter().enumerate() {
450        if i == tag_line_idx {
451            continue;
452        }
453        result_lines.push(line.to_string());
454    }
455
456    let change = ChangeDescription::RemoveTag {
457        tag: tag.to_string(),
458    };
459
460    Ok((reassemble(&result_lines, body, content), change))
461}
462
463/// Reassemble a file from frontmatter lines and body, preserving the original line ending style.
464fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
465    let line_ending = if original.contains("\r\n") {
466        "\r\n"
467    } else {
468        "\n"
469    };
470
471    let mut result = fm_lines.join(line_ending);
472    result.push_str(line_ending);
473    result.push_str(body);
474    result
475}
476
477/// Options controlling how a write touches the filesystem.
478///
479/// Default values match the previous (pre-Phase-A) `std::fs::write`
480/// behaviour: atomic at the rename, but not durable against power loss.
481/// Set `fsync: true` to force the data to stable storage before the
482/// write returns.
483///
484/// Designed to be `Copy + Default + serde::*` so it can be piped through
485/// the mutation builders, configured from env vars or config files, or
486/// surfaced over a Tauri command.
487#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
488pub struct WriteOptions {
489    /// fsync the temp file's data, then fsync the parent directory's
490    /// metadata, before considering the write complete. Adds one or two
491    /// disk-flush IOs per write — typically 1–10ms on consumer SSDs and
492    /// 10–50ms on spinning disks. Required for durable mutations (e.g. a
493    /// long-lived Tauri app) that need to survive sudden power loss with
494    /// the change preserved.
495    pub fsync: bool,
496}
497
498impl WriteOptions {
499    /// Convenience: opts with `fsync` set to true.
500    pub fn durable() -> Self {
501        Self { fsync: true }
502    }
503}
504
505/// fsync a directory so its dirent updates (renames, creates, removes)
506/// are durable. Best-effort on Windows: opening a directory for sync
507/// is supported on NTFS but not all filesystems.
508pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
509    let f = std::fs::File::open(dir)?;
510    f.sync_all()
511}
512
513/// Atomically replace the contents of `path` with `content` using the
514/// default [`WriteOptions`] (no fsync). See [`atomic_write_with`] for the
515/// version that takes options.
516pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
517    atomic_write_with(path, content, WriteOptions::default())
518}
519
520/// Atomically replace the contents of `path` with `content`, honoring
521/// [`WriteOptions`].
522///
523/// Writes to a temp file in the same directory, optionally fsyncs the
524/// temp file's data, then renames over the target. The rename is atomic
525/// on POSIX same-filesystem operations and on Windows with
526/// `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`. Concurrent readers either
527/// see the full old content or the full new content; they never see a
528/// partial write.
529///
530/// When `opts.fsync` is true, the temp file is fsynced before rename
531/// AND the parent directory is fsynced after rename, so the change
532/// survives power loss the moment this function returns Ok.
533pub fn atomic_write_with(
534    path: &std::path::Path,
535    content: &str,
536    opts: WriteOptions,
537) -> std::io::Result<()> {
538    let dir = path.parent().ok_or_else(|| {
539        std::io::Error::other(format!(
540            "atomic_write target has no parent dir: {}",
541            path.display()
542        ))
543    })?;
544
545    // tempfile::NamedTempFile creates a uniquely-named file in `dir`,
546    // which guarantees same-filesystem rename below. The file is
547    // cleaned up automatically on drop if `persist` isn't called (e.g.
548    // if the write fails mid-way).
549    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
550
551    use std::io::Write;
552    tmp.write_all(content.as_bytes())?;
553    tmp.flush()?;
554
555    // Optional data fsync before the rename. The order matters: if we
556    // rename first and then fsync, a power loss between the rename and
557    // the fsync can leave the rename visible but pointing at undefined
558    // data. POSIX guarantees that data fsynced before the rename is
559    // durable as soon as the rename's directory entry is durable.
560    if opts.fsync {
561        tmp.as_file().sync_all()?;
562    }
563
564    // `persist` does the atomic rename. On error it returns the temp
565    // file plus the io::Error; we discard the temp file (it'll be
566    // cleaned up by Drop) and propagate just the error.
567    tmp.persist(path).map_err(|e| e.error)?;
568
569    if opts.fsync {
570        fsync_dir(dir)?;
571    }
572    Ok(())
573}
574
575/// Write a WriteResult to disk atomically with default options.
576pub fn apply(result: &WriteResult) -> std::io::Result<()> {
577    apply_with(result, WriteOptions::default())
578}
579
580/// Write a WriteResult to disk atomically, honoring [`WriteOptions`].
581pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
582    atomic_write_with(&result.path, &result.modified_content, opts)
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    const MOVIE_FILE: &str = "\
590---
591aliases:
592tags:
593  - type/leaf
594  - topic/movies
595  - source/video
596  - genre/drama
597status: to-watch
598rating:
599director: Sam Mendes
600year: 2019
601related-to:
602---
603
604Part of [[Watchlist]]
605";
606
607    const CHINESE_FILE: &str = "\
608---
609aliases:
610- kuài
611tags:
612- type/concept
613- topic/chinese
614- source/self-study
615pinyin: kuài
616anlam: hızlı
617tür: sifat
618hsk: 1
619kaliplar:
620- kalip: 快乐
621  pinyin: kuàilè
622  anlam: mutlu, neşeli
623ornekler:
624- cumle: 他跑得很快。
625  pinyin: Tā pǎo de hěn kuài.
626  anlam: O çok hızlı koşuyor.
627related-to:
628---
629
630# 快 (kuài) — hızlı
631
632Body text.
633";
634
635    #[test]
636    fn set_existing_scalar_field() {
637        let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
638        assert!(result.contains("status: watched"));
639        assert!(!result.contains("to-watch"));
640        // Body preserved
641        assert!(result.contains("Part of [[Watchlist]]"));
642        match change {
643            ChangeDescription::SetField {
644                field,
645                old_value,
646                new_value,
647            } => {
648                assert_eq!(field, "status");
649                assert_eq!(old_value, "to-watch");
650                assert_eq!(new_value, "watched");
651            }
652            _ => panic!("expected SetField"),
653        }
654    }
655
656    #[test]
657    fn set_null_field() {
658        let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
659        assert!(result.contains("rating: 8"));
660    }
661
662    #[test]
663    fn set_new_field() {
664        let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
665        assert!(result.contains("language: English"));
666        // Should be inserted before closing ---
667        let closing_idx = result.rfind("\n---\n").unwrap();
668        let lang_idx = result.find("language: English").unwrap();
669        assert!(lang_idx < closing_idx);
670    }
671
672    #[test]
673    fn set_complex_field_rejected() {
674        let result = set_field(CHINESE_FILE, "kaliplar", "something");
675        assert!(result.is_err());
676    }
677
678    #[test]
679    fn set_value_needing_quotes() {
680        let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
681        assert!(result.contains("note: 'key: value'"));
682    }
683
684    #[test]
685    fn unset_scalar_field() {
686        let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
687        assert!(!result.contains("director:"));
688        // Other fields preserved
689        assert!(result.contains("status: to-watch"));
690        assert!(result.contains("year: 2019"));
691        assert!(result.contains("Part of [[Watchlist]]"));
692    }
693
694    #[test]
695    fn unset_list_field() {
696        let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
697        assert!(!result.contains("kaliplar:"));
698        assert!(!result.contains("快乐"));
699        // Other fields preserved
700        assert!(result.contains("pinyin: kuài"));
701        assert!(result.contains("Body text."));
702    }
703
704    #[test]
705    fn unset_nonexistent_field() {
706        let result = unset_field(MOVIE_FILE, "nonexistent");
707        assert!(result.is_err());
708    }
709
710    #[test]
711    fn add_tag_2space_indent() {
712        let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
713        assert!(result.contains("  - genre/war"));
714        // Existing tags still present
715        assert!(result.contains("  - type/leaf"));
716        assert!(result.contains("  - genre/drama"));
717    }
718
719    #[test]
720    fn add_tag_0indent() {
721        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
722        assert!(result.contains("- topic/hsk1"));
723        // Existing tags preserved
724        assert!(result.contains("- type/concept"));
725        assert!(result.contains("- topic/chinese"));
726    }
727
728    #[test]
729    fn remove_tag_2space_indent() {
730        let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
731        assert!(!result.contains("genre/drama"));
732        // Other tags preserved
733        assert!(result.contains("  - type/leaf"));
734        assert!(result.contains("  - source/video"));
735    }
736
737    #[test]
738    fn remove_tag_0indent() {
739        let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
740        assert!(!result.contains("topic/chinese"));
741        assert!(result.contains("- type/concept"));
742        assert!(result.contains("- source/self-study"));
743    }
744
745    #[test]
746    fn remove_nonexistent_tag() {
747        let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
748        assert!(result.is_err());
749    }
750
751    #[test]
752    fn body_preserved_after_set() {
753        let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
754        assert!(result.ends_with("Part of [[Watchlist]]\n"));
755    }
756
757    #[test]
758    fn body_preserved_after_unset() {
759        let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
760        assert!(result.contains("# 快 (kuài) — hızlı"));
761        assert!(result.contains("Body text."));
762    }
763
764    #[test]
765    fn body_preserved_after_add_tag() {
766        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
767        assert!(result.contains("# 快 (kuài) — hızlı"));
768    }
769
770    #[test]
771    fn chinese_content_preserved() {
772        let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
773        assert!(result.contains("pinyin: kuài"));
774        assert!(result.contains("anlam: hızlı"));
775        assert!(result.contains("tür: sifat"));
776        assert!(result.contains("kalip: 快乐"));
777        assert!(result.contains("cumle: 他跑得很快。"));
778    }
779
780    // ── Safety checks ─────────────────────────
781
782    #[test]
783    fn set_field_rejects_flow_style() {
784        let content = "---\ntags: [a, b, c]\n---\nBody.\n";
785        let result = set_field(content, "tags", "x");
786        assert!(result.is_err());
787        let err = result.unwrap_err().to_string();
788        assert!(err.contains("flow-style"));
789    }
790
791    #[test]
792    fn set_field_rejects_multiline_scalar() {
793        let content = "---\ndescription: |\n  Multi line\n  content here\n---\nBody.\n";
794        let result = set_field(content, "description", "new value");
795        assert!(result.is_err());
796        let err = result.unwrap_err().to_string();
797        assert!(err.contains("multiline"));
798    }
799
800    #[test]
801    fn add_tag_rejects_flow_style() {
802        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
803        let result = add_tag(content, "topic/new");
804        assert!(result.is_err());
805        let err = result.unwrap_err().to_string();
806        assert!(err.contains("flow-style"));
807    }
808
809    #[test]
810    fn remove_tag_rejects_flow_style() {
811        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
812        let result = remove_tag(content, "topic/ai");
813        assert!(result.is_err());
814        let err = result.unwrap_err().to_string();
815        assert!(err.contains("flow-style"));
816    }
817}