1use crate::error::{Result, VaultdbError};
7
8#[derive(Debug)]
10pub enum ChangeDescription {
11 SetField {
12 field: String,
13 old_value: String,
14 new_value: String,
15 },
16 UnsetField {
17 field: String,
18 old_value: String,
19 },
20 AddTag {
21 tag: String,
22 },
23 RemoveTag {
24 tag: String,
25 },
26}
27
28impl std::fmt::Display for ChangeDescription {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 ChangeDescription::SetField {
32 field,
33 old_value,
34 new_value,
35 } => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
36 ChangeDescription::UnsetField { field, old_value } => {
37 write!(f, "unset {} (was: {})", field, old_value)
38 }
39 ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
40 ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
41 }
42 }
43}
44
45pub struct WriteResult {
47 pub path: std::path::PathBuf,
48 pub original_content: String,
49 pub modified_content: String,
50 pub changes: Vec<ChangeDescription>,
51}
52
53fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
56 let lines: Vec<&str> = content.lines().collect();
57
58 if lines.is_empty() || lines[0].trim() != "---" {
59 return Err(VaultdbError::NoFrontmatter("content".into()));
60 }
61
62 let close_idx = lines[1..]
64 .iter()
65 .position(|l| l.trim() == "---")
66 .map(|i| i + 1); match close_idx {
69 Some(idx) => {
70 let fm_lines = &lines[..=idx];
71 let mut byte_offset = 0;
74 for (i, line) in content.lines().enumerate() {
75 byte_offset += line.len();
76 if byte_offset < content.len() {
78 if content.as_bytes().get(byte_offset) == Some(&b'\r') {
79 byte_offset += 1; }
81 if byte_offset < content.len() {
82 byte_offset += 1; }
84 }
85 if i == idx {
86 break;
87 }
88 }
89 let body = &content[byte_offset..];
90 Ok((fm_lines.to_vec(), body))
91 }
92 None => Err(VaultdbError::NoFrontmatter("content".into())),
93 }
94}
95
96fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
99 for line in fm_lines.iter().skip(key_line_idx + 1) {
101 let trimmed = line.trim();
102
103 if trimmed == "---"
105 || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
106 {
107 break;
108 }
109
110 if trimmed.starts_with("- ") || trimmed == "-" {
111 let dash_pos = line.find('-').unwrap();
113 let prefix = &line[..dash_pos];
114 return format!("{}- ", prefix);
115 }
116 }
117 " - ".to_string()
119}
120
121fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
123 let patterns = [format!("{}:", key), format!("{} :", key)];
124 for (i, line) in fm_lines.iter().enumerate() {
125 if i == 0 || line.trim() == "---" {
126 continue; }
128 let trimmed = line.trim_start();
129 for pattern in &patterns {
130 if trimmed.starts_with(pattern) {
131 let after = &trimmed[pattern.len()..];
133 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
134 return Some(i);
135 }
136 }
137 }
138 }
139 None
140}
141
142fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
144 let key_line = fm_lines[key_line_idx];
145 let key_indent = key_line.len() - key_line.trim_start().len();
146
147 let after_colon = key_line.trim_start();
149 if let Some(colon_pos) = after_colon.find(':') {
150 let value_part = after_colon[colon_pos + 1..].trim();
151 if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
152 return 1;
154 }
155 }
156
157 let mut extent = 1;
158 for line in fm_lines.iter().skip(key_line_idx + 1) {
159 let trimmed = line.trim();
160
161 if trimmed == "---" {
163 break;
164 }
165
166 if trimmed.is_empty() {
168 break;
169 }
170
171 let line_indent = line.len() - line.trim_start().len();
172
173 if line_indent <= key_indent && !trimmed.starts_with('-') {
176 break;
177 }
178
179 if line_indent == key_indent && trimmed.starts_with('-') {
181 extent += 1;
182 continue;
183 }
184
185 if line_indent > key_indent {
187 extent += 1;
188 continue;
189 }
190
191 break;
192 }
193 extent
194}
195
196fn is_flow_style_list(line: &str) -> bool {
198 if let Some(colon_pos) = line.find(':') {
199 let value = line[colon_pos + 1..].trim();
200 value.starts_with('[') && value.ends_with(']')
201 } else {
202 false
203 }
204}
205
206fn is_multiline_scalar(line: &str) -> bool {
208 if let Some(colon_pos) = line.find(':') {
209 let value = line[colon_pos + 1..].trim();
210 value == "|"
211 || value == ">"
212 || value == "|+"
213 || value == "|-"
214 || value == ">+"
215 || value == ">-"
216 } else {
217 false
218 }
219}
220
221pub fn quote_value(value: &str) -> String {
223 yaml_quote_value(value)
224}
225
226fn yaml_quote_value(value: &str) -> String {
227 let needs_quoting = value.contains(':')
228 || value.contains('#')
229 || value.contains('[')
230 || value.contains(']')
231 || value.contains('{')
232 || value.contains('}')
233 || value.contains('\'')
234 || value.contains('"')
235 || value.contains('&')
236 || value.contains('*')
237 || value.contains('!')
238 || value.contains('|')
239 || value.contains('>')
240 || value.contains('%')
241 || value.contains('@')
242 || value.starts_with(' ')
243 || value.ends_with(' ')
244 || value.starts_with('-')
245 || value.starts_with('?');
246
247 if needs_quoting {
248 if value.contains('\'') {
249 format!("\"{}\"", value.replace('"', "\\\""))
250 } else {
251 format!("'{}'", value)
252 }
253 } else {
254 value.to_string()
255 }
256}
257
258pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
260 let (fm_lines, body) = split_frontmatter(content)?;
261 let quoted_value = yaml_quote_value(value);
262
263 if let Some(key_idx) = find_key_line(&fm_lines, key) {
264 let extent = field_extent(&fm_lines, key_idx);
265 if extent > 1 {
266 return Err(VaultdbError::InvalidFrontmatter {
267 file: String::new(),
268 reason: format!(
269 "field '{}' is a complex type (list/map). Use --unset first, then re-add.",
270 key
271 ),
272 });
273 }
274
275 if is_flow_style_list(fm_lines[key_idx]) {
276 return Err(VaultdbError::InvalidFrontmatter {
277 file: String::new(),
278 reason: format!(
279 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
280 key
281 ),
282 });
283 }
284
285 if is_multiline_scalar(fm_lines[key_idx]) {
286 return Err(VaultdbError::InvalidFrontmatter {
287 file: String::new(),
288 reason: format!(
289 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
290 key
291 ),
292 });
293 }
294
295 let old_line = fm_lines[key_idx];
296 let old_value = old_line
298 .find(':')
299 .map(|pos| old_line[pos + 1..].trim())
300 .unwrap_or("")
301 .to_string();
302
303 let new_line = format!("{}: {}", key, quoted_value);
304
305 let mut result_lines: Vec<String> = Vec::new();
306 for (i, line) in fm_lines.iter().enumerate() {
307 if i == key_idx {
308 result_lines.push(new_line.clone());
309 } else {
310 result_lines.push(line.to_string());
311 }
312 }
313
314 let change = ChangeDescription::SetField {
315 field: key.to_string(),
316 old_value,
317 new_value: value.to_string(),
318 };
319
320 Ok((reassemble(&result_lines, body, content), change))
321 } else {
322 let mut result_lines: Vec<String> = Vec::new();
324 for (i, line) in fm_lines.iter().enumerate() {
325 if i == fm_lines.len() - 1 && line.trim() == "---" {
326 result_lines.push(format!("{}: {}", key, quoted_value));
327 }
328 result_lines.push(line.to_string());
329 }
330
331 let change = ChangeDescription::SetField {
332 field: key.to_string(),
333 old_value: String::new(),
334 new_value: value.to_string(),
335 };
336
337 Ok((reassemble(&result_lines, body, content), change))
338 }
339}
340
341pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
343 let (fm_lines, body) = split_frontmatter(content)?;
344
345 let key_idx =
346 find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
347 file: String::new(),
348 reason: format!("field '{}' not found", key),
349 })?;
350
351 let extent = field_extent(&fm_lines, key_idx);
352 let old_value = fm_lines[key_idx]
353 .find(':')
354 .map(|pos| fm_lines[key_idx][pos + 1..].trim())
355 .unwrap_or("")
356 .to_string();
357
358 let mut result_lines: Vec<String> = Vec::new();
359 for (i, line) in fm_lines.iter().enumerate() {
360 if i >= key_idx && i < key_idx + extent {
361 continue; }
363 result_lines.push(line.to_string());
364 }
365
366 let change = ChangeDescription::UnsetField {
367 field: key.to_string(),
368 old_value,
369 };
370
371 Ok((reassemble(&result_lines, body, content), change))
372}
373
374pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
376 let (fm_lines, body) = split_frontmatter(content)?;
377
378 let key_idx =
379 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
380 file: String::new(),
381 reason: "no 'tags' field found".into(),
382 })?;
383
384 if is_flow_style_list(fm_lines[key_idx]) {
385 return Err(VaultdbError::InvalidFrontmatter {
386 file: String::new(),
387 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
388 });
389 }
390
391 let indent_prefix = detect_list_indent(&fm_lines, key_idx);
392 let extent = field_extent(&fm_lines, key_idx);
393 let insert_after = key_idx + extent - 1; let new_tag_line = format!("{}{}", indent_prefix, tag);
396
397 let mut result_lines: Vec<String> = Vec::new();
398 for (i, line) in fm_lines.iter().enumerate() {
399 result_lines.push(line.to_string());
400 if i == insert_after {
401 result_lines.push(new_tag_line.clone());
402 }
403 }
404
405 let change = ChangeDescription::AddTag {
406 tag: tag.to_string(),
407 };
408
409 Ok((reassemble(&result_lines, body, content), change))
410}
411
412pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
414 let (fm_lines, body) = split_frontmatter(content)?;
415
416 let key_idx =
417 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
418 file: String::new(),
419 reason: "no 'tags' field found".into(),
420 })?;
421
422 if is_flow_style_list(fm_lines[key_idx]) {
423 return Err(VaultdbError::InvalidFrontmatter {
424 file: String::new(),
425 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
426 });
427 }
428
429 let extent = field_extent(&fm_lines, key_idx);
430
431 let tag_line_idx = fm_lines
433 .iter()
434 .enumerate()
435 .skip(key_idx + 1)
436 .take(extent.saturating_sub(1))
437 .find_map(|(i, line)| {
438 let trimmed = line.trim();
439 let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
440 (tag_value == tag).then_some(i)
441 });
442
443 let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
444 file: String::new(),
445 reason: format!("tag '{}' not found in tags list", tag),
446 })?;
447
448 let mut result_lines: Vec<String> = Vec::new();
449 for (i, line) in fm_lines.iter().enumerate() {
450 if i == tag_line_idx {
451 continue;
452 }
453 result_lines.push(line.to_string());
454 }
455
456 let change = ChangeDescription::RemoveTag {
457 tag: tag.to_string(),
458 };
459
460 Ok((reassemble(&result_lines, body, content), change))
461}
462
463fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
465 let line_ending = if original.contains("\r\n") {
466 "\r\n"
467 } else {
468 "\n"
469 };
470
471 let mut result = fm_lines.join(line_ending);
472 result.push_str(line_ending);
473 result.push_str(body);
474 result
475}
476
477#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
488pub struct WriteOptions {
489 pub fsync: bool,
496}
497
498impl WriteOptions {
499 pub fn durable() -> Self {
501 Self { fsync: true }
502 }
503}
504
505pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
509 let f = std::fs::File::open(dir)?;
510 f.sync_all()
511}
512
513pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
517 atomic_write_with(path, content, WriteOptions::default())
518}
519
520pub fn atomic_write_with(
534 path: &std::path::Path,
535 content: &str,
536 opts: WriteOptions,
537) -> std::io::Result<()> {
538 let dir = path.parent().ok_or_else(|| {
539 std::io::Error::other(format!(
540 "atomic_write target has no parent dir: {}",
541 path.display()
542 ))
543 })?;
544
545 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
550
551 use std::io::Write;
552 tmp.write_all(content.as_bytes())?;
553 tmp.flush()?;
554
555 if opts.fsync {
561 tmp.as_file().sync_all()?;
562 }
563
564 tmp.persist(path).map_err(|e| e.error)?;
568
569 if opts.fsync {
570 fsync_dir(dir)?;
571 }
572 Ok(())
573}
574
575pub fn apply(result: &WriteResult) -> std::io::Result<()> {
577 apply_with(result, WriteOptions::default())
578}
579
580pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
582 atomic_write_with(&result.path, &result.modified_content, opts)
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 const MOVIE_FILE: &str = "\
590---
591aliases:
592tags:
593 - type/leaf
594 - topic/movies
595 - source/video
596 - genre/drama
597status: to-watch
598rating:
599director: Sam Mendes
600year: 2019
601related-to:
602---
603
604Part of [[Watchlist]]
605";
606
607 const CHINESE_FILE: &str = "\
608---
609aliases:
610- kuài
611tags:
612- type/concept
613- topic/chinese
614- source/self-study
615pinyin: kuài
616anlam: hızlı
617tür: sifat
618hsk: 1
619kaliplar:
620- kalip: 快乐
621 pinyin: kuàilè
622 anlam: mutlu, neşeli
623ornekler:
624- cumle: 他跑得很快。
625 pinyin: Tā pǎo de hěn kuài.
626 anlam: O çok hızlı koşuyor.
627related-to:
628---
629
630# 快 (kuài) — hızlı
631
632Body text.
633";
634
635 #[test]
636 fn set_existing_scalar_field() {
637 let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
638 assert!(result.contains("status: watched"));
639 assert!(!result.contains("to-watch"));
640 assert!(result.contains("Part of [[Watchlist]]"));
642 match change {
643 ChangeDescription::SetField {
644 field,
645 old_value,
646 new_value,
647 } => {
648 assert_eq!(field, "status");
649 assert_eq!(old_value, "to-watch");
650 assert_eq!(new_value, "watched");
651 }
652 _ => panic!("expected SetField"),
653 }
654 }
655
656 #[test]
657 fn set_null_field() {
658 let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
659 assert!(result.contains("rating: 8"));
660 }
661
662 #[test]
663 fn set_new_field() {
664 let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
665 assert!(result.contains("language: English"));
666 let closing_idx = result.rfind("\n---\n").unwrap();
668 let lang_idx = result.find("language: English").unwrap();
669 assert!(lang_idx < closing_idx);
670 }
671
672 #[test]
673 fn set_complex_field_rejected() {
674 let result = set_field(CHINESE_FILE, "kaliplar", "something");
675 assert!(result.is_err());
676 }
677
678 #[test]
679 fn set_value_needing_quotes() {
680 let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
681 assert!(result.contains("note: 'key: value'"));
682 }
683
684 #[test]
685 fn unset_scalar_field() {
686 let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
687 assert!(!result.contains("director:"));
688 assert!(result.contains("status: to-watch"));
690 assert!(result.contains("year: 2019"));
691 assert!(result.contains("Part of [[Watchlist]]"));
692 }
693
694 #[test]
695 fn unset_list_field() {
696 let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
697 assert!(!result.contains("kaliplar:"));
698 assert!(!result.contains("快乐"));
699 assert!(result.contains("pinyin: kuài"));
701 assert!(result.contains("Body text."));
702 }
703
704 #[test]
705 fn unset_nonexistent_field() {
706 let result = unset_field(MOVIE_FILE, "nonexistent");
707 assert!(result.is_err());
708 }
709
710 #[test]
711 fn add_tag_2space_indent() {
712 let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
713 assert!(result.contains(" - genre/war"));
714 assert!(result.contains(" - type/leaf"));
716 assert!(result.contains(" - genre/drama"));
717 }
718
719 #[test]
720 fn add_tag_0indent() {
721 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
722 assert!(result.contains("- topic/hsk1"));
723 assert!(result.contains("- type/concept"));
725 assert!(result.contains("- topic/chinese"));
726 }
727
728 #[test]
729 fn remove_tag_2space_indent() {
730 let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
731 assert!(!result.contains("genre/drama"));
732 assert!(result.contains(" - type/leaf"));
734 assert!(result.contains(" - source/video"));
735 }
736
737 #[test]
738 fn remove_tag_0indent() {
739 let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
740 assert!(!result.contains("topic/chinese"));
741 assert!(result.contains("- type/concept"));
742 assert!(result.contains("- source/self-study"));
743 }
744
745 #[test]
746 fn remove_nonexistent_tag() {
747 let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
748 assert!(result.is_err());
749 }
750
751 #[test]
752 fn body_preserved_after_set() {
753 let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
754 assert!(result.ends_with("Part of [[Watchlist]]\n"));
755 }
756
757 #[test]
758 fn body_preserved_after_unset() {
759 let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
760 assert!(result.contains("# 快 (kuài) — hızlı"));
761 assert!(result.contains("Body text."));
762 }
763
764 #[test]
765 fn body_preserved_after_add_tag() {
766 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
767 assert!(result.contains("# 快 (kuài) — hızlı"));
768 }
769
770 #[test]
771 fn chinese_content_preserved() {
772 let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
773 assert!(result.contains("pinyin: kuài"));
774 assert!(result.contains("anlam: hızlı"));
775 assert!(result.contains("tür: sifat"));
776 assert!(result.contains("kalip: 快乐"));
777 assert!(result.contains("cumle: 他跑得很快。"));
778 }
779
780 #[test]
783 fn set_field_rejects_flow_style() {
784 let content = "---\ntags: [a, b, c]\n---\nBody.\n";
785 let result = set_field(content, "tags", "x");
786 assert!(result.is_err());
787 let err = result.unwrap_err().to_string();
788 assert!(err.contains("flow-style"));
789 }
790
791 #[test]
792 fn set_field_rejects_multiline_scalar() {
793 let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
794 let result = set_field(content, "description", "new value");
795 assert!(result.is_err());
796 let err = result.unwrap_err().to_string();
797 assert!(err.contains("multiline"));
798 }
799
800 #[test]
801 fn add_tag_rejects_flow_style() {
802 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
803 let result = add_tag(content, "topic/new");
804 assert!(result.is_err());
805 let err = result.unwrap_err().to_string();
806 assert!(err.contains("flow-style"));
807 }
808
809 #[test]
810 fn remove_tag_rejects_flow_style() {
811 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
812 let result = remove_tag(content, "topic/ai");
813 assert!(result.is_err());
814 let err = result.unwrap_err().to_string();
815 assert!(err.contains("flow-style"));
816 }
817}