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