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