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}
28
29impl std::fmt::Display for ChangeDescription {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            ChangeDescription::SetField {
33                field,
34                old_value,
35                new_value,
36            } => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
37            ChangeDescription::UnsetField { field, old_value } => {
38                write!(f, "unset {} (was: {})", field, old_value)
39            }
40            ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
41            ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
42        }
43    }
44}
45
46/// Result of a write operation on a single file.
47pub struct WriteResult {
48    pub path: std::path::PathBuf,
49    pub original_content: String,
50    pub modified_content: String,
51    pub changes: Vec<ChangeDescription>,
52}
53
54/// Split file content into frontmatter lines and body.
55/// Returns (frontmatter_lines_including_delimiters, body_str).
56fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
57    let lines: Vec<&str> = content.lines().collect();
58
59    if lines.is_empty() || lines[0].trim() != "---" {
60        return Err(VaultdbError::NoFrontmatter("content".into()));
61    }
62
63    // Find closing ---
64    let close_idx = lines[1..]
65        .iter()
66        .position(|l| l.trim() == "---")
67        .map(|i| i + 1); // offset by 1 because we started from lines[1..]
68
69    match close_idx {
70        Some(idx) => {
71            let fm_lines = &lines[..=idx];
72            // Body starts after the closing --- line
73            // We need to find the byte offset of the body
74            let mut byte_offset = 0;
75            for (i, line) in content.lines().enumerate() {
76                byte_offset += line.len();
77                // Account for the newline character
78                if byte_offset < content.len() {
79                    if content.as_bytes().get(byte_offset) == Some(&b'\r') {
80                        byte_offset += 1; // \r
81                    }
82                    if byte_offset < content.len() {
83                        byte_offset += 1; // \n
84                    }
85                }
86                if i == idx {
87                    break;
88                }
89            }
90            let body = &content[byte_offset..];
91            Ok((fm_lines.to_vec(), body))
92        }
93        None => Err(VaultdbError::NoFrontmatter("content".into())),
94    }
95}
96
97/// Detect the indentation used for list items under a key.
98/// Returns the prefix string (e.g., "  - " or "- ").
99fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
100    // Look at the line after the key line
101    for line in fm_lines.iter().skip(key_line_idx + 1) {
102        let trimmed = line.trim();
103
104        // Stop if we hit another top-level key or delimiter
105        if trimmed == "---"
106            || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
107        {
108            break;
109        }
110
111        if trimmed.starts_with("- ") || trimmed == "-" {
112            // Return the actual prefix including whitespace
113            let dash_pos = line.find('-').unwrap();
114            let prefix = &line[..dash_pos];
115            return format!("{}- ", prefix);
116        }
117    }
118    // Default: 2-space indent
119    "  - ".to_string()
120}
121
122/// Find the line index of a top-level key in frontmatter lines (between delimiters).
123fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
124    let patterns = [format!("{}:", key), format!("{} :", key)];
125    for (i, line) in fm_lines.iter().enumerate() {
126        if i == 0 || line.trim() == "---" {
127            continue; // skip delimiters
128        }
129        let trimmed = line.trim_start();
130        for pattern in &patterns {
131            if trimmed.starts_with(pattern) {
132                // Make sure we matched the full key, not a prefix
133                let after = &trimmed[pattern.len()..];
134                if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
135                    return Some(i);
136                }
137            }
138        }
139    }
140    None
141}
142
143/// Determine how many lines a field spans (including nested list/map items).
144fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
145    let key_line = fm_lines[key_line_idx];
146    let key_indent = key_line.len() - key_line.trim_start().len();
147
148    // Check if the key has an inline value (not a list/map)
149    let after_colon = key_line.trim_start();
150    if let Some(colon_pos) = after_colon.find(':') {
151        let value_part = after_colon[colon_pos + 1..].trim();
152        if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
153            // Inline scalar value — single line
154            return 1;
155        }
156    }
157
158    let mut extent = 1;
159    for line in fm_lines.iter().skip(key_line_idx + 1) {
160        let trimmed = line.trim();
161
162        // Stop at closing delimiter
163        if trimmed == "---" {
164            break;
165        }
166
167        // Empty line ends the field
168        if trimmed.is_empty() {
169            break;
170        }
171
172        let line_indent = line.len() - line.trim_start().len();
173
174        // If this line is at the same or lesser indentation and doesn't start with '-',
175        // it's a new top-level key
176        if line_indent <= key_indent && !trimmed.starts_with('-') {
177            break;
178        }
179
180        // Lines starting with '-' at the same indent level are list items of this key
181        if line_indent == key_indent && trimmed.starts_with('-') {
182            extent += 1;
183            continue;
184        }
185
186        // Indented lines are continuations
187        if line_indent > key_indent {
188            extent += 1;
189            continue;
190        }
191
192        break;
193    }
194    extent
195}
196
197/// Check if a field line uses flow-style list syntax: `key: [a, b, c]`
198fn is_flow_style_list(line: &str) -> bool {
199    if let Some(colon_pos) = line.find(':') {
200        let value = line[colon_pos + 1..].trim();
201        value.starts_with('[') && value.ends_with(']')
202    } else {
203        false
204    }
205}
206
207/// Check if a field line uses a multiline scalar indicator: `key: |` or `key: >`
208fn is_multiline_scalar(line: &str) -> bool {
209    if let Some(colon_pos) = line.find(':') {
210        let value = line[colon_pos + 1..].trim();
211        value == "|"
212            || value == ">"
213            || value == "|+"
214            || value == "|-"
215            || value == ">+"
216            || value == ">-"
217    } else {
218        false
219    }
220}
221
222/// Quote a YAML value if it contains special characters.
223pub fn quote_value(value: &str) -> String {
224    yaml_quote_value(value)
225}
226
227fn yaml_quote_value(value: &str) -> String {
228    let needs_quoting = 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.contains('@')
243        || value.starts_with(' ')
244        || value.ends_with(' ')
245        || value.starts_with('-')
246        || value.starts_with('?');
247
248    if needs_quoting {
249        if value.contains('\'') {
250            format!("\"{}\"", value.replace('"', "\\\""))
251        } else {
252            format!("'{}'", value)
253        }
254    } else {
255        value.to_string()
256    }
257}
258
259/// Set a scalar field to a new value in the frontmatter.
260pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
261    let (fm_lines, body) = split_frontmatter(content)?;
262    let quoted_value = yaml_quote_value(value);
263
264    if let Some(key_idx) = find_key_line(&fm_lines, key) {
265        let extent = field_extent(&fm_lines, key_idx);
266        if extent > 1 {
267            return Err(VaultdbError::InvalidFrontmatter {
268                file: String::new(),
269                reason: format!(
270                    "field '{}' is a complex type (list/map). Use --unset first, then re-add.",
271                    key
272                ),
273            });
274        }
275
276        if is_flow_style_list(fm_lines[key_idx]) {
277            return Err(VaultdbError::InvalidFrontmatter {
278                file: String::new(),
279                reason: format!(
280                    "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
281                    key
282                ),
283            });
284        }
285
286        if is_multiline_scalar(fm_lines[key_idx]) {
287            return Err(VaultdbError::InvalidFrontmatter {
288                file: String::new(),
289                reason: format!(
290                    "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
291                    key
292                ),
293            });
294        }
295
296        let old_line = fm_lines[key_idx];
297        // Extract old value for the change description
298        let old_value = old_line
299            .find(':')
300            .map(|pos| old_line[pos + 1..].trim())
301            .unwrap_or("")
302            .to_string();
303
304        let new_line = format!("{}: {}", key, quoted_value);
305
306        let mut result_lines: Vec<String> = Vec::new();
307        for (i, line) in fm_lines.iter().enumerate() {
308            if i == key_idx {
309                result_lines.push(new_line.clone());
310            } else {
311                result_lines.push(line.to_string());
312            }
313        }
314
315        let change = ChangeDescription::SetField {
316            field: key.to_string(),
317            old_value,
318            new_value: value.to_string(),
319        };
320
321        Ok((reassemble(&result_lines, body, content), change))
322    } else {
323        // Key doesn't exist — insert before closing ---
324        let mut result_lines: Vec<String> = Vec::new();
325        for (i, line) in fm_lines.iter().enumerate() {
326            if i == fm_lines.len() - 1 && line.trim() == "---" {
327                result_lines.push(format!("{}: {}", key, quoted_value));
328            }
329            result_lines.push(line.to_string());
330        }
331
332        let change = ChangeDescription::SetField {
333            field: key.to_string(),
334            old_value: String::new(),
335            new_value: value.to_string(),
336        };
337
338        Ok((reassemble(&result_lines, body, content), change))
339    }
340}
341
342/// Set a field to a `Value::List` or `Value::Map`, emitting block-style YAML
343/// across multiple lines.
344///
345/// Use [`set_field`] for scalars. This function is the structured counterpart:
346/// it preserves the typed shape of the value through the write rather than
347/// flattening it to a quoted string scalar.
348///
349/// Behavior:
350/// - Key absent → insert the rendered block before the closing `---`.
351/// - Key present as a block-style list/map → replace the multi-line span.
352/// - Key present as a scalar → replace the single line with the block.
353/// - Key present as flow-style (`[a, b]`) or a multiline scalar (`|`, `>`):
354///   refuses with `InvalidFrontmatter`, matching [`set_field`]'s stance —
355///   we won't try to round-trip those styles.
356pub fn set_field_block(
357    content: &str,
358    key: &str,
359    value: &Value,
360) -> Result<(String, ChangeDescription)> {
361    if !matches!(value, Value::List(_) | Value::Map(_)) {
362        return Err(VaultdbError::InvalidFrontmatter {
363            file: String::new(),
364            reason: format!(
365                "set_field_block called with a scalar value for '{}'; use set_field instead",
366                key
367            ),
368        });
369    }
370
371    let (fm_lines, body) = split_frontmatter(content)?;
372
373    // Render `{key: value}` as YAML so the key sits at column 0 and the
374    // contents indent below it. Splitting by lines gives us the block we
375    // splice into the frontmatter.
376    let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
377    wrapper.insert(key.to_string(), value.clone());
378    let rendered =
379        serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
380            file: String::new(),
381            reason: format!("rendering '{}' as YAML: {}", key, e),
382        })?;
383    let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
384    let new_value_summary = serde_yaml::to_string(value)
385        .map(|s| s.trim_end().to_string())
386        .unwrap_or_default();
387
388    if let Some(key_idx) = find_key_line(&fm_lines, key) {
389        if is_flow_style_list(fm_lines[key_idx]) {
390            return Err(VaultdbError::InvalidFrontmatter {
391                file: String::new(),
392                reason: format!(
393                    "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
394                    key
395                ),
396            });
397        }
398
399        if is_multiline_scalar(fm_lines[key_idx]) {
400            return Err(VaultdbError::InvalidFrontmatter {
401                file: String::new(),
402                reason: format!(
403                    "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
404                    key
405                ),
406            });
407        }
408
409        let extent = field_extent(&fm_lines, key_idx);
410        // Old value summary: either the inline scalar (extent == 1) or the
411        // joined block (extent > 1). Used for ChangeDescription only.
412        let old_value = if extent == 1 {
413            fm_lines[key_idx]
414                .find(':')
415                .map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
416                .unwrap_or_default()
417        } else {
418            fm_lines[key_idx..key_idx + extent].join("\n")
419        };
420
421        let mut result_lines: Vec<String> = Vec::new();
422        for line in &fm_lines[..key_idx] {
423            result_lines.push((*line).to_string());
424        }
425        result_lines.extend(new_lines.iter().cloned());
426        for line in &fm_lines[key_idx + extent..] {
427            result_lines.push((*line).to_string());
428        }
429
430        let change = ChangeDescription::SetField {
431            field: key.to_string(),
432            old_value,
433            new_value: new_value_summary,
434        };
435
436        Ok((reassemble(&result_lines, body, content), change))
437    } else {
438        // Key doesn't exist — insert the block before the closing ---.
439        let mut result_lines: Vec<String> = Vec::new();
440        for (i, line) in fm_lines.iter().enumerate() {
441            if i == fm_lines.len() - 1 && line.trim() == "---" {
442                result_lines.extend(new_lines.iter().cloned());
443            }
444            result_lines.push((*line).to_string());
445        }
446
447        let change = ChangeDescription::SetField {
448            field: key.to_string(),
449            old_value: String::new(),
450            new_value: new_value_summary,
451        };
452
453        Ok((reassemble(&result_lines, body, content), change))
454    }
455}
456
457/// Remove a field entirely from the frontmatter.
458pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
459    let (fm_lines, body) = split_frontmatter(content)?;
460
461    let key_idx =
462        find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
463            file: String::new(),
464            reason: format!("field '{}' not found", key),
465        })?;
466
467    let extent = field_extent(&fm_lines, key_idx);
468    let old_value = fm_lines[key_idx]
469        .find(':')
470        .map(|pos| fm_lines[key_idx][pos + 1..].trim())
471        .unwrap_or("")
472        .to_string();
473
474    let mut result_lines: Vec<String> = Vec::new();
475    for (i, line) in fm_lines.iter().enumerate() {
476        if i >= key_idx && i < key_idx + extent {
477            continue; // skip this field's lines
478        }
479        result_lines.push(line.to_string());
480    }
481
482    let change = ChangeDescription::UnsetField {
483        field: key.to_string(),
484        old_value,
485    };
486
487    Ok((reassemble(&result_lines, body, content), change))
488}
489
490/// Add a tag to the tags list.
491pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
492    let (fm_lines, body) = split_frontmatter(content)?;
493
494    let key_idx =
495        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
496            file: String::new(),
497            reason: "no 'tags' field found".into(),
498        })?;
499
500    if is_flow_style_list(fm_lines[key_idx]) {
501        return Err(VaultdbError::InvalidFrontmatter {
502            file: String::new(),
503            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
504        });
505    }
506
507    let indent_prefix = detect_list_indent(&fm_lines, key_idx);
508    let extent = field_extent(&fm_lines, key_idx);
509    let insert_after = key_idx + extent - 1; // last line of the tags section
510
511    let new_tag_line = format!("{}{}", indent_prefix, tag);
512
513    let mut result_lines: Vec<String> = Vec::new();
514    for (i, line) in fm_lines.iter().enumerate() {
515        result_lines.push(line.to_string());
516        if i == insert_after {
517            result_lines.push(new_tag_line.clone());
518        }
519    }
520
521    let change = ChangeDescription::AddTag {
522        tag: tag.to_string(),
523    };
524
525    Ok((reassemble(&result_lines, body, content), change))
526}
527
528/// Remove a tag from the tags list.
529pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
530    let (fm_lines, body) = split_frontmatter(content)?;
531
532    let key_idx =
533        find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
534            file: String::new(),
535            reason: "no 'tags' field found".into(),
536        })?;
537
538    if is_flow_style_list(fm_lines[key_idx]) {
539        return Err(VaultdbError::InvalidFrontmatter {
540            file: String::new(),
541            reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
542        });
543    }
544
545    let extent = field_extent(&fm_lines, key_idx);
546
547    // Find the tag line within the tags section
548    let tag_line_idx = fm_lines
549        .iter()
550        .enumerate()
551        .skip(key_idx + 1)
552        .take(extent.saturating_sub(1))
553        .find_map(|(i, line)| {
554            let trimmed = line.trim();
555            let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
556            (tag_value == tag).then_some(i)
557        });
558
559    let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
560        file: String::new(),
561        reason: format!("tag '{}' not found in tags list", tag),
562    })?;
563
564    let mut result_lines: Vec<String> = Vec::new();
565    for (i, line) in fm_lines.iter().enumerate() {
566        if i == tag_line_idx {
567            continue;
568        }
569        result_lines.push(line.to_string());
570    }
571
572    let change = ChangeDescription::RemoveTag {
573        tag: tag.to_string(),
574    };
575
576    Ok((reassemble(&result_lines, body, content), change))
577}
578
579/// Reassemble a file from frontmatter lines and body, preserving the original line ending style.
580fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
581    let line_ending = if original.contains("\r\n") {
582        "\r\n"
583    } else {
584        "\n"
585    };
586
587    let mut result = fm_lines.join(line_ending);
588    result.push_str(line_ending);
589    result.push_str(body);
590    result
591}
592
593/// Options controlling how a write touches the filesystem.
594///
595/// Default values match the previous (pre-Phase-A) `std::fs::write`
596/// behaviour: atomic at the rename, but not durable against power loss.
597/// Set `fsync: true` to force the data to stable storage before the
598/// write returns.
599///
600/// Designed to be `Copy + Default + serde::*` so it can be piped through
601/// the mutation builders, configured from env vars or config files, or
602/// surfaced over a Tauri command.
603#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
604pub struct WriteOptions {
605    /// fsync the temp file's data, then fsync the parent directory's
606    /// metadata, before considering the write complete. Adds one or two
607    /// disk-flush IOs per write — typically 1–10ms on consumer SSDs and
608    /// 10–50ms on spinning disks. Required for durable mutations (e.g. a
609    /// long-lived Tauri app) that need to survive sudden power loss with
610    /// the change preserved.
611    pub fsync: bool,
612}
613
614impl WriteOptions {
615    /// Convenience: opts with `fsync` set to true.
616    pub fn durable() -> Self {
617        Self { fsync: true }
618    }
619}
620
621/// fsync a directory so its dirent updates (renames, creates, removes)
622/// are durable. Best-effort on Windows: opening a directory for sync
623/// is supported on NTFS but not all filesystems.
624pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
625    let f = std::fs::File::open(dir)?;
626    f.sync_all()
627}
628
629/// Atomically replace the contents of `path` with `content` using the
630/// default [`WriteOptions`] (no fsync). See [`atomic_write_with`] for the
631/// version that takes options.
632pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
633    atomic_write_with(path, content, WriteOptions::default())
634}
635
636/// Atomically replace the contents of `path` with `content`, honoring
637/// [`WriteOptions`].
638///
639/// Writes to a temp file in the same directory, optionally fsyncs the
640/// temp file's data, then renames over the target. The rename is atomic
641/// on POSIX same-filesystem operations and on Windows with
642/// `MoveFileEx(MOVEFILE_REPLACE_EXISTING)`. Concurrent readers either
643/// see the full old content or the full new content; they never see a
644/// partial write.
645///
646/// When `opts.fsync` is true, the temp file is fsynced before rename
647/// AND the parent directory is fsynced after rename, so the change
648/// survives power loss the moment this function returns Ok.
649pub fn atomic_write_with(
650    path: &std::path::Path,
651    content: &str,
652    opts: WriteOptions,
653) -> std::io::Result<()> {
654    let dir = path.parent().ok_or_else(|| {
655        std::io::Error::other(format!(
656            "atomic_write target has no parent dir: {}",
657            path.display()
658        ))
659    })?;
660
661    // tempfile::NamedTempFile creates a uniquely-named file in `dir`,
662    // which guarantees same-filesystem rename below. The file is
663    // cleaned up automatically on drop if `persist` isn't called (e.g.
664    // if the write fails mid-way).
665    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
666
667    use std::io::Write;
668    tmp.write_all(content.as_bytes())?;
669    tmp.flush()?;
670
671    // Optional data fsync before the rename. The order matters: if we
672    // rename first and then fsync, a power loss between the rename and
673    // the fsync can leave the rename visible but pointing at undefined
674    // data. POSIX guarantees that data fsynced before the rename is
675    // durable as soon as the rename's directory entry is durable.
676    if opts.fsync {
677        tmp.as_file().sync_all()?;
678    }
679
680    // `persist` does the atomic rename. On error it returns the temp
681    // file plus the io::Error; we discard the temp file (it'll be
682    // cleaned up by Drop) and propagate just the error.
683    tmp.persist(path).map_err(|e| e.error)?;
684
685    if opts.fsync {
686        fsync_dir(dir)?;
687    }
688    Ok(())
689}
690
691/// Write a WriteResult to disk atomically with default options.
692pub fn apply(result: &WriteResult) -> std::io::Result<()> {
693    apply_with(result, WriteOptions::default())
694}
695
696/// Write a WriteResult to disk atomically, honoring [`WriteOptions`].
697pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
698    atomic_write_with(&result.path, &result.modified_content, opts)
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    const MOVIE_FILE: &str = "\
706---
707aliases:
708tags:
709  - type/leaf
710  - topic/movies
711  - source/video
712  - genre/drama
713status: to-watch
714rating:
715director: Sam Mendes
716year: 2019
717related-to:
718---
719
720Part of [[Watchlist]]
721";
722
723    const CHINESE_FILE: &str = "\
724---
725aliases:
726- kuài
727tags:
728- type/concept
729- topic/chinese
730- source/self-study
731pinyin: kuài
732anlam: hızlı
733tür: sifat
734hsk: 1
735kaliplar:
736- kalip: 快乐
737  pinyin: kuàilè
738  anlam: mutlu, neşeli
739ornekler:
740- cumle: 他跑得很快。
741  pinyin: Tā pǎo de hěn kuài.
742  anlam: O çok hızlı koşuyor.
743related-to:
744---
745
746# 快 (kuài) — hızlı
747
748Body text.
749";
750
751    #[test]
752    fn set_existing_scalar_field() {
753        let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
754        assert!(result.contains("status: watched"));
755        assert!(!result.contains("to-watch"));
756        // Body preserved
757        assert!(result.contains("Part of [[Watchlist]]"));
758        match change {
759            ChangeDescription::SetField {
760                field,
761                old_value,
762                new_value,
763            } => {
764                assert_eq!(field, "status");
765                assert_eq!(old_value, "to-watch");
766                assert_eq!(new_value, "watched");
767            }
768            _ => panic!("expected SetField"),
769        }
770    }
771
772    #[test]
773    fn set_null_field() {
774        let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
775        assert!(result.contains("rating: 8"));
776    }
777
778    #[test]
779    fn set_new_field() {
780        let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
781        assert!(result.contains("language: English"));
782        // Should be inserted before closing ---
783        let closing_idx = result.rfind("\n---\n").unwrap();
784        let lang_idx = result.find("language: English").unwrap();
785        assert!(lang_idx < closing_idx);
786    }
787
788    #[test]
789    fn set_complex_field_rejected() {
790        let result = set_field(CHINESE_FILE, "kaliplar", "something");
791        assert!(result.is_err());
792    }
793
794    #[test]
795    fn set_value_needing_quotes() {
796        let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
797        assert!(result.contains("note: 'key: value'"));
798    }
799
800    // ── set_field_block (typed list/map writes) ──────────────────────────
801
802    #[test]
803    fn set_field_block_inserts_new_list_as_block_yaml() {
804        let value = Value::List(vec![Value::String("kedi".into())]);
805        let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
806        // Block-style: a `key:` line followed by `- item` lines, NOT
807        // `anlamlar: '- kedi'` (the pre-fix quoted-scalar shape).
808        assert!(result.contains("anlamlar:\n- kedi"));
809        assert!(!result.contains("anlamlar: '- kedi'"));
810        // Inserted before closing `---`.
811        let closing_idx = result.rfind("\n---\n").unwrap();
812        assert!(result.find("anlamlar:").unwrap() < closing_idx);
813        match change {
814            ChangeDescription::SetField {
815                field, new_value, ..
816            } => {
817                assert_eq!(field, "anlamlar");
818                assert_eq!(new_value.trim_end(), "- kedi");
819            }
820            _ => panic!("expected SetField"),
821        }
822    }
823
824    #[test]
825    fn set_field_block_multi_item_list_round_trips() {
826        let value = Value::List(vec![
827            Value::String("猫が好きです。".into()),
828            Value::String("私の猫は黒いです。".into()),
829        ]);
830        let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
831        assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
832        // Parse the result back and confirm the field is a list, not a string.
833        let fm_end = result[4..].find("\n---\n").unwrap() + 4;
834        let fm = &result[4..fm_end];
835        let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
836        let items = parsed
837            .as_mapping()
838            .and_then(|m| m.get("ornekler_jp"))
839            .and_then(|v| v.as_sequence())
840            .expect("ornekler_jp must round-trip as a YAML sequence");
841        assert_eq!(items.len(), 2);
842    }
843
844    #[test]
845    fn set_field_block_replaces_existing_block_list() {
846        // CHINESE_FILE has `kaliplar` as a block-style list of maps. Replace
847        // it with a fresh list and confirm the old span is gone.
848        let value = Value::List(vec![Value::String("replaced".into())]);
849        let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
850        assert!(result.contains("kaliplar:\n- replaced"));
851        assert!(!result.contains("快乐")); // old item gone
852        assert!(!result.contains("kuàilè")); // old nested key gone
853        // Adjacent fields preserved.
854        assert!(result.contains("hsk: 1"));
855        assert!(result.contains("ornekler:"));
856    }
857
858    #[test]
859    fn set_field_block_writes_map_as_nested_yaml() {
860        let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
861        m.insert("k1".into(), Value::String("v1".into()));
862        m.insert("k2".into(), Value::Integer(2));
863        let value = Value::Map(m);
864        let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
865        assert!(result.contains("meta:\n  k1: v1\n  k2: 2"));
866    }
867
868    #[test]
869    fn set_field_block_rejects_flow_style_existing() {
870        let content = "---\ntags: [a, b]\n---\nbody\n";
871        let value = Value::List(vec![Value::String("c".into())]);
872        let err = set_field_block(content, "tags", &value).unwrap_err();
873        let msg = format!("{}", err);
874        assert!(msg.contains("flow-style"), "got: {}", msg);
875    }
876
877    #[test]
878    fn set_field_block_rejects_scalar_value() {
879        // Programmer error guard: scalars must go through set_field, not here.
880        let err =
881            set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
882        let msg = format!("{}", err);
883        assert!(msg.contains("scalar value"), "got: {}", msg);
884    }
885
886    #[test]
887    fn unset_scalar_field() {
888        let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
889        assert!(!result.contains("director:"));
890        // Other fields preserved
891        assert!(result.contains("status: to-watch"));
892        assert!(result.contains("year: 2019"));
893        assert!(result.contains("Part of [[Watchlist]]"));
894    }
895
896    #[test]
897    fn unset_list_field() {
898        let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
899        assert!(!result.contains("kaliplar:"));
900        assert!(!result.contains("快乐"));
901        // Other fields preserved
902        assert!(result.contains("pinyin: kuài"));
903        assert!(result.contains("Body text."));
904    }
905
906    #[test]
907    fn unset_nonexistent_field() {
908        let result = unset_field(MOVIE_FILE, "nonexistent");
909        assert!(result.is_err());
910    }
911
912    #[test]
913    fn add_tag_2space_indent() {
914        let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
915        assert!(result.contains("  - genre/war"));
916        // Existing tags still present
917        assert!(result.contains("  - type/leaf"));
918        assert!(result.contains("  - genre/drama"));
919    }
920
921    #[test]
922    fn add_tag_0indent() {
923        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
924        assert!(result.contains("- topic/hsk1"));
925        // Existing tags preserved
926        assert!(result.contains("- type/concept"));
927        assert!(result.contains("- topic/chinese"));
928    }
929
930    #[test]
931    fn remove_tag_2space_indent() {
932        let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
933        assert!(!result.contains("genre/drama"));
934        // Other tags preserved
935        assert!(result.contains("  - type/leaf"));
936        assert!(result.contains("  - source/video"));
937    }
938
939    #[test]
940    fn remove_tag_0indent() {
941        let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
942        assert!(!result.contains("topic/chinese"));
943        assert!(result.contains("- type/concept"));
944        assert!(result.contains("- source/self-study"));
945    }
946
947    #[test]
948    fn remove_nonexistent_tag() {
949        let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
950        assert!(result.is_err());
951    }
952
953    #[test]
954    fn body_preserved_after_set() {
955        let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
956        assert!(result.ends_with("Part of [[Watchlist]]\n"));
957    }
958
959    #[test]
960    fn body_preserved_after_unset() {
961        let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
962        assert!(result.contains("# 快 (kuài) — hızlı"));
963        assert!(result.contains("Body text."));
964    }
965
966    #[test]
967    fn body_preserved_after_add_tag() {
968        let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
969        assert!(result.contains("# 快 (kuài) — hızlı"));
970    }
971
972    #[test]
973    fn chinese_content_preserved() {
974        let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
975        assert!(result.contains("pinyin: kuài"));
976        assert!(result.contains("anlam: hızlı"));
977        assert!(result.contains("tür: sifat"));
978        assert!(result.contains("kalip: 快乐"));
979        assert!(result.contains("cumle: 他跑得很快。"));
980    }
981
982    // ── Safety checks ─────────────────────────
983
984    #[test]
985    fn set_field_rejects_flow_style() {
986        let content = "---\ntags: [a, b, c]\n---\nBody.\n";
987        let result = set_field(content, "tags", "x");
988        assert!(result.is_err());
989        let err = result.unwrap_err().to_string();
990        assert!(err.contains("flow-style"));
991    }
992
993    #[test]
994    fn set_field_rejects_multiline_scalar() {
995        let content = "---\ndescription: |\n  Multi line\n  content here\n---\nBody.\n";
996        let result = set_field(content, "description", "new value");
997        assert!(result.is_err());
998        let err = result.unwrap_err().to_string();
999        assert!(err.contains("multiline"));
1000    }
1001
1002    #[test]
1003    fn add_tag_rejects_flow_style() {
1004        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1005        let result = add_tag(content, "topic/new");
1006        assert!(result.is_err());
1007        let err = result.unwrap_err().to_string();
1008        assert!(err.contains("flow-style"));
1009    }
1010
1011    #[test]
1012    fn remove_tag_rejects_flow_style() {
1013        let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1014        let result = remove_tag(content, "topic/ai");
1015        assert!(result.is_err());
1016        let err = result.unwrap_err().to_string();
1017        assert!(err.contains("flow-style"));
1018    }
1019}