1use crate::error::{Result, VaultdbError};
7use crate::record::Value;
8
9#[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
46pub 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
54fn 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 let close_idx = lines[1..]
65 .iter()
66 .position(|l| l.trim() == "---")
67 .map(|i| i + 1); match close_idx {
70 Some(idx) => {
71 let fm_lines = &lines[..=idx];
72 let mut byte_offset = 0;
75 for (i, line) in content.lines().enumerate() {
76 byte_offset += line.len();
77 if byte_offset < content.len() {
79 if content.as_bytes().get(byte_offset) == Some(&b'\r') {
80 byte_offset += 1; }
82 if byte_offset < content.len() {
83 byte_offset += 1; }
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
97fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
100 for line in fm_lines.iter().skip(key_line_idx + 1) {
102 let trimmed = line.trim();
103
104 if trimmed == "---"
106 || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
107 {
108 break;
109 }
110
111 if trimmed.starts_with("- ") || trimmed == "-" {
112 let dash_pos = line.find('-').unwrap();
114 let prefix = &line[..dash_pos];
115 return format!("{}- ", prefix);
116 }
117 }
118 " - ".to_string()
120}
121
122fn 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; }
129 let trimmed = line.trim_start();
130 for pattern in &patterns {
131 if trimmed.starts_with(pattern) {
132 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
143fn 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 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 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 if trimmed == "---" {
164 break;
165 }
166
167 if trimmed.is_empty() {
169 break;
170 }
171
172 let line_indent = line.len() - line.trim_start().len();
173
174 if line_indent <= key_indent && !trimmed.starts_with('-') {
177 break;
178 }
179
180 if line_indent == key_indent && trimmed.starts_with('-') {
182 extent += 1;
183 continue;
184 }
185
186 if line_indent > key_indent {
188 extent += 1;
189 continue;
190 }
191
192 break;
193 }
194 extent
195}
196
197fn 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
207fn 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
222pub fn quote_value(value: &str) -> String {
224 yaml_quote_value(value)
225}
226
227fn yaml_quote_value(value: &str) -> String {
228 let needs_quoting = value.contains(':')
229 || value.contains('#')
230 || value.contains('[')
231 || value.contains(']')
232 || value.contains('{')
233 || value.contains('}')
234 || value.contains('\'')
235 || value.contains('"')
236 || value.contains('&')
237 || value.contains('*')
238 || value.contains('!')
239 || value.contains('|')
240 || value.contains('>')
241 || value.contains('%')
242 || value.contains('@')
243 || value.starts_with(' ')
244 || value.ends_with(' ')
245 || value.starts_with('-')
246 || value.starts_with('?');
247
248 if needs_quoting {
249 if value.contains('\'') {
250 format!("\"{}\"", value.replace('"', "\\\""))
251 } else {
252 format!("'{}'", value)
253 }
254 } else {
255 value.to_string()
256 }
257}
258
259pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
261 let (fm_lines, body) = split_frontmatter(content)?;
262 let quoted_value = yaml_quote_value(value);
263
264 if let Some(key_idx) = find_key_line(&fm_lines, key) {
265 let extent = field_extent(&fm_lines, key_idx);
266 if extent > 1 {
267 return Err(VaultdbError::InvalidFrontmatter {
268 file: String::new(),
269 reason: format!(
270 "field '{}' is a complex type (list/map). Use --unset first, then re-add.",
271 key
272 ),
273 });
274 }
275
276 if is_flow_style_list(fm_lines[key_idx]) {
277 return Err(VaultdbError::InvalidFrontmatter {
278 file: String::new(),
279 reason: format!(
280 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
281 key
282 ),
283 });
284 }
285
286 if is_multiline_scalar(fm_lines[key_idx]) {
287 return Err(VaultdbError::InvalidFrontmatter {
288 file: String::new(),
289 reason: format!(
290 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
291 key
292 ),
293 });
294 }
295
296 let old_line = fm_lines[key_idx];
297 let old_value = old_line
299 .find(':')
300 .map(|pos| old_line[pos + 1..].trim())
301 .unwrap_or("")
302 .to_string();
303
304 let new_line = format!("{}: {}", key, quoted_value);
305
306 let mut result_lines: Vec<String> = Vec::new();
307 for (i, line) in fm_lines.iter().enumerate() {
308 if i == key_idx {
309 result_lines.push(new_line.clone());
310 } else {
311 result_lines.push(line.to_string());
312 }
313 }
314
315 let change = ChangeDescription::SetField {
316 field: key.to_string(),
317 old_value,
318 new_value: value.to_string(),
319 };
320
321 Ok((reassemble(&result_lines, body, content), change))
322 } else {
323 let mut result_lines: Vec<String> = Vec::new();
325 for (i, line) in fm_lines.iter().enumerate() {
326 if i == fm_lines.len() - 1 && line.trim() == "---" {
327 result_lines.push(format!("{}: {}", key, quoted_value));
328 }
329 result_lines.push(line.to_string());
330 }
331
332 let change = ChangeDescription::SetField {
333 field: key.to_string(),
334 old_value: String::new(),
335 new_value: value.to_string(),
336 };
337
338 Ok((reassemble(&result_lines, body, content), change))
339 }
340}
341
342pub fn set_field_block(
357 content: &str,
358 key: &str,
359 value: &Value,
360) -> Result<(String, ChangeDescription)> {
361 if !matches!(value, Value::List(_) | Value::Map(_)) {
362 return Err(VaultdbError::InvalidFrontmatter {
363 file: String::new(),
364 reason: format!(
365 "set_field_block called with a scalar value for '{}'; use set_field instead",
366 key
367 ),
368 });
369 }
370
371 let (fm_lines, body) = split_frontmatter(content)?;
372
373 let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
377 wrapper.insert(key.to_string(), value.clone());
378 let rendered =
379 serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
380 file: String::new(),
381 reason: format!("rendering '{}' as YAML: {}", key, e),
382 })?;
383 let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
384 let new_value_summary = serde_yaml::to_string(value)
385 .map(|s| s.trim_end().to_string())
386 .unwrap_or_default();
387
388 if let Some(key_idx) = find_key_line(&fm_lines, key) {
389 if is_flow_style_list(fm_lines[key_idx]) {
390 return Err(VaultdbError::InvalidFrontmatter {
391 file: String::new(),
392 reason: format!(
393 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
394 key
395 ),
396 });
397 }
398
399 if is_multiline_scalar(fm_lines[key_idx]) {
400 return Err(VaultdbError::InvalidFrontmatter {
401 file: String::new(),
402 reason: format!(
403 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
404 key
405 ),
406 });
407 }
408
409 let extent = field_extent(&fm_lines, key_idx);
410 let old_value = if extent == 1 {
413 fm_lines[key_idx]
414 .find(':')
415 .map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
416 .unwrap_or_default()
417 } else {
418 fm_lines[key_idx..key_idx + extent].join("\n")
419 };
420
421 let mut result_lines: Vec<String> = Vec::new();
422 for line in &fm_lines[..key_idx] {
423 result_lines.push((*line).to_string());
424 }
425 result_lines.extend(new_lines.iter().cloned());
426 for line in &fm_lines[key_idx + extent..] {
427 result_lines.push((*line).to_string());
428 }
429
430 let change = ChangeDescription::SetField {
431 field: key.to_string(),
432 old_value,
433 new_value: new_value_summary,
434 };
435
436 Ok((reassemble(&result_lines, body, content), change))
437 } else {
438 let mut result_lines: Vec<String> = Vec::new();
440 for (i, line) in fm_lines.iter().enumerate() {
441 if i == fm_lines.len() - 1 && line.trim() == "---" {
442 result_lines.extend(new_lines.iter().cloned());
443 }
444 result_lines.push((*line).to_string());
445 }
446
447 let change = ChangeDescription::SetField {
448 field: key.to_string(),
449 old_value: String::new(),
450 new_value: new_value_summary,
451 };
452
453 Ok((reassemble(&result_lines, body, content), change))
454 }
455}
456
457pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
459 let (fm_lines, body) = split_frontmatter(content)?;
460
461 let key_idx =
462 find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
463 file: String::new(),
464 reason: format!("field '{}' not found", key),
465 })?;
466
467 let extent = field_extent(&fm_lines, key_idx);
468 let old_value = fm_lines[key_idx]
469 .find(':')
470 .map(|pos| fm_lines[key_idx][pos + 1..].trim())
471 .unwrap_or("")
472 .to_string();
473
474 let mut result_lines: Vec<String> = Vec::new();
475 for (i, line) in fm_lines.iter().enumerate() {
476 if i >= key_idx && i < key_idx + extent {
477 continue; }
479 result_lines.push(line.to_string());
480 }
481
482 let change = ChangeDescription::UnsetField {
483 field: key.to_string(),
484 old_value,
485 };
486
487 Ok((reassemble(&result_lines, body, content), change))
488}
489
490pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
492 let (fm_lines, body) = split_frontmatter(content)?;
493
494 let key_idx =
495 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
496 file: String::new(),
497 reason: "no 'tags' field found".into(),
498 })?;
499
500 if is_flow_style_list(fm_lines[key_idx]) {
501 return Err(VaultdbError::InvalidFrontmatter {
502 file: String::new(),
503 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
504 });
505 }
506
507 let indent_prefix = detect_list_indent(&fm_lines, key_idx);
508 let extent = field_extent(&fm_lines, key_idx);
509 let insert_after = key_idx + extent - 1; let new_tag_line = format!("{}{}", indent_prefix, tag);
512
513 let mut result_lines: Vec<String> = Vec::new();
514 for (i, line) in fm_lines.iter().enumerate() {
515 result_lines.push(line.to_string());
516 if i == insert_after {
517 result_lines.push(new_tag_line.clone());
518 }
519 }
520
521 let change = ChangeDescription::AddTag {
522 tag: tag.to_string(),
523 };
524
525 Ok((reassemble(&result_lines, body, content), change))
526}
527
528pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
530 let (fm_lines, body) = split_frontmatter(content)?;
531
532 let key_idx =
533 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
534 file: String::new(),
535 reason: "no 'tags' field found".into(),
536 })?;
537
538 if is_flow_style_list(fm_lines[key_idx]) {
539 return Err(VaultdbError::InvalidFrontmatter {
540 file: String::new(),
541 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
542 });
543 }
544
545 let extent = field_extent(&fm_lines, key_idx);
546
547 let tag_line_idx = fm_lines
549 .iter()
550 .enumerate()
551 .skip(key_idx + 1)
552 .take(extent.saturating_sub(1))
553 .find_map(|(i, line)| {
554 let trimmed = line.trim();
555 let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
556 (tag_value == tag).then_some(i)
557 });
558
559 let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
560 file: String::new(),
561 reason: format!("tag '{}' not found in tags list", tag),
562 })?;
563
564 let mut result_lines: Vec<String> = Vec::new();
565 for (i, line) in fm_lines.iter().enumerate() {
566 if i == tag_line_idx {
567 continue;
568 }
569 result_lines.push(line.to_string());
570 }
571
572 let change = ChangeDescription::RemoveTag {
573 tag: tag.to_string(),
574 };
575
576 Ok((reassemble(&result_lines, body, content), change))
577}
578
579fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
581 let line_ending = if original.contains("\r\n") {
582 "\r\n"
583 } else {
584 "\n"
585 };
586
587 let mut result = fm_lines.join(line_ending);
588 result.push_str(line_ending);
589 result.push_str(body);
590 result
591}
592
593#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
604pub struct WriteOptions {
605 pub fsync: bool,
612}
613
614impl WriteOptions {
615 pub fn durable() -> Self {
617 Self { fsync: true }
618 }
619}
620
621pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
625 let f = std::fs::File::open(dir)?;
626 f.sync_all()
627}
628
629pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
633 atomic_write_with(path, content, WriteOptions::default())
634}
635
636pub fn atomic_create_with(
648 path: &std::path::Path,
649 content: &str,
650 opts: WriteOptions,
651) -> std::io::Result<()> {
652 let dir = path.parent().ok_or_else(|| {
653 std::io::Error::other(format!(
654 "atomic_create target has no parent dir: {}",
655 path.display()
656 ))
657 })?;
658
659 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
660 use std::io::Write;
661 tmp.write_all(content.as_bytes())?;
662 tmp.flush()?;
663
664 if opts.fsync {
665 tmp.as_file().sync_all()?;
666 }
667
668 tmp.persist_noclobber(path).map_err(|e| e.error)?;
669
670 if opts.fsync {
671 fsync_dir(dir)?;
672 }
673 Ok(())
674}
675
676pub fn atomic_write_with(
690 path: &std::path::Path,
691 content: &str,
692 opts: WriteOptions,
693) -> std::io::Result<()> {
694 let dir = path.parent().ok_or_else(|| {
695 std::io::Error::other(format!(
696 "atomic_write target has no parent dir: {}",
697 path.display()
698 ))
699 })?;
700
701 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
706
707 use std::io::Write;
708 tmp.write_all(content.as_bytes())?;
709 tmp.flush()?;
710
711 if opts.fsync {
717 tmp.as_file().sync_all()?;
718 }
719
720 tmp.persist(path).map_err(|e| e.error)?;
724
725 if opts.fsync {
726 fsync_dir(dir)?;
727 }
728 Ok(())
729}
730
731pub fn apply(result: &WriteResult) -> std::io::Result<()> {
733 apply_with(result, WriteOptions::default())
734}
735
736pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
738 atomic_write_with(&result.path, &result.modified_content, opts)
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 const MOVIE_FILE: &str = "\
746---
747aliases:
748tags:
749 - type/leaf
750 - topic/movies
751 - source/video
752 - genre/drama
753status: to-watch
754rating:
755director: Sam Mendes
756year: 2019
757related-to:
758---
759
760Part of [[Watchlist]]
761";
762
763 const CHINESE_FILE: &str = "\
764---
765aliases:
766- kuài
767tags:
768- type/concept
769- topic/chinese
770- source/self-study
771pinyin: kuài
772anlam: hızlı
773tür: sifat
774hsk: 1
775kaliplar:
776- kalip: 快乐
777 pinyin: kuàilè
778 anlam: mutlu, neşeli
779ornekler:
780- cumle: 他跑得很快。
781 pinyin: Tā pǎo de hěn kuài.
782 anlam: O çok hızlı koşuyor.
783related-to:
784---
785
786# 快 (kuài) — hızlı
787
788Body text.
789";
790
791 #[test]
792 fn set_existing_scalar_field() {
793 let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
794 assert!(result.contains("status: watched"));
795 assert!(!result.contains("to-watch"));
796 assert!(result.contains("Part of [[Watchlist]]"));
798 match change {
799 ChangeDescription::SetField {
800 field,
801 old_value,
802 new_value,
803 } => {
804 assert_eq!(field, "status");
805 assert_eq!(old_value, "to-watch");
806 assert_eq!(new_value, "watched");
807 }
808 _ => panic!("expected SetField"),
809 }
810 }
811
812 #[test]
813 fn set_null_field() {
814 let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
815 assert!(result.contains("rating: 8"));
816 }
817
818 #[test]
819 fn set_new_field() {
820 let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
821 assert!(result.contains("language: English"));
822 let closing_idx = result.rfind("\n---\n").unwrap();
824 let lang_idx = result.find("language: English").unwrap();
825 assert!(lang_idx < closing_idx);
826 }
827
828 #[test]
829 fn set_complex_field_rejected() {
830 let result = set_field(CHINESE_FILE, "kaliplar", "something");
831 assert!(result.is_err());
832 }
833
834 #[test]
835 fn set_value_needing_quotes() {
836 let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
837 assert!(result.contains("note: 'key: value'"));
838 }
839
840 #[test]
843 fn set_field_block_inserts_new_list_as_block_yaml() {
844 let value = Value::List(vec![Value::String("kedi".into())]);
845 let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
846 assert!(result.contains("anlamlar:\n- kedi"));
849 assert!(!result.contains("anlamlar: '- kedi'"));
850 let closing_idx = result.rfind("\n---\n").unwrap();
852 assert!(result.find("anlamlar:").unwrap() < closing_idx);
853 match change {
854 ChangeDescription::SetField {
855 field, new_value, ..
856 } => {
857 assert_eq!(field, "anlamlar");
858 assert_eq!(new_value.trim_end(), "- kedi");
859 }
860 _ => panic!("expected SetField"),
861 }
862 }
863
864 #[test]
865 fn set_field_block_multi_item_list_round_trips() {
866 let value = Value::List(vec![
867 Value::String("猫が好きです。".into()),
868 Value::String("私の猫は黒いです。".into()),
869 ]);
870 let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
871 assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
872 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
874 let fm = &result[4..fm_end];
875 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
876 let items = parsed
877 .as_mapping()
878 .and_then(|m| m.get("ornekler_jp"))
879 .and_then(|v| v.as_sequence())
880 .expect("ornekler_jp must round-trip as a YAML sequence");
881 assert_eq!(items.len(), 2);
882 }
883
884 #[test]
885 fn set_field_block_replaces_existing_block_list() {
886 let value = Value::List(vec![Value::String("replaced".into())]);
889 let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
890 assert!(result.contains("kaliplar:\n- replaced"));
891 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè")); assert!(result.contains("hsk: 1"));
895 assert!(result.contains("ornekler:"));
896 }
897
898 #[test]
899 fn set_field_block_writes_map_as_nested_yaml() {
900 let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
901 m.insert("k1".into(), Value::String("v1".into()));
902 m.insert("k2".into(), Value::Integer(2));
903 let value = Value::Map(m);
904 let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
905 assert!(result.contains("meta:\n k1: v1\n k2: 2"));
906 }
907
908 #[test]
909 fn set_field_block_rejects_flow_style_existing() {
910 let content = "---\ntags: [a, b]\n---\nbody\n";
911 let value = Value::List(vec![Value::String("c".into())]);
912 let err = set_field_block(content, "tags", &value).unwrap_err();
913 let msg = format!("{}", err);
914 assert!(msg.contains("flow-style"), "got: {}", msg);
915 }
916
917 #[test]
918 fn set_field_block_rejects_scalar_value() {
919 let err =
921 set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
922 let msg = format!("{}", err);
923 assert!(msg.contains("scalar value"), "got: {}", msg);
924 }
925
926 #[test]
927 fn unset_scalar_field() {
928 let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
929 assert!(!result.contains("director:"));
930 assert!(result.contains("status: to-watch"));
932 assert!(result.contains("year: 2019"));
933 assert!(result.contains("Part of [[Watchlist]]"));
934 }
935
936 #[test]
937 fn unset_list_field() {
938 let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
939 assert!(!result.contains("kaliplar:"));
940 assert!(!result.contains("快乐"));
941 assert!(result.contains("pinyin: kuài"));
943 assert!(result.contains("Body text."));
944 }
945
946 #[test]
947 fn unset_nonexistent_field() {
948 let result = unset_field(MOVIE_FILE, "nonexistent");
949 assert!(result.is_err());
950 }
951
952 #[test]
953 fn add_tag_2space_indent() {
954 let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
955 assert!(result.contains(" - genre/war"));
956 assert!(result.contains(" - type/leaf"));
958 assert!(result.contains(" - genre/drama"));
959 }
960
961 #[test]
962 fn add_tag_0indent() {
963 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
964 assert!(result.contains("- topic/hsk1"));
965 assert!(result.contains("- type/concept"));
967 assert!(result.contains("- topic/chinese"));
968 }
969
970 #[test]
971 fn remove_tag_2space_indent() {
972 let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
973 assert!(!result.contains("genre/drama"));
974 assert!(result.contains(" - type/leaf"));
976 assert!(result.contains(" - source/video"));
977 }
978
979 #[test]
980 fn remove_tag_0indent() {
981 let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
982 assert!(!result.contains("topic/chinese"));
983 assert!(result.contains("- type/concept"));
984 assert!(result.contains("- source/self-study"));
985 }
986
987 #[test]
988 fn remove_nonexistent_tag() {
989 let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
990 assert!(result.is_err());
991 }
992
993 #[test]
994 fn body_preserved_after_set() {
995 let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
996 assert!(result.ends_with("Part of [[Watchlist]]\n"));
997 }
998
999 #[test]
1000 fn body_preserved_after_unset() {
1001 let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
1002 assert!(result.contains("# 快 (kuài) — hızlı"));
1003 assert!(result.contains("Body text."));
1004 }
1005
1006 #[test]
1007 fn body_preserved_after_add_tag() {
1008 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1009 assert!(result.contains("# 快 (kuài) — hızlı"));
1010 }
1011
1012 #[test]
1013 fn chinese_content_preserved() {
1014 let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
1015 assert!(result.contains("pinyin: kuài"));
1016 assert!(result.contains("anlam: hızlı"));
1017 assert!(result.contains("tür: sifat"));
1018 assert!(result.contains("kalip: 快乐"));
1019 assert!(result.contains("cumle: 他跑得很快。"));
1020 }
1021
1022 #[test]
1025 fn set_field_rejects_flow_style() {
1026 let content = "---\ntags: [a, b, c]\n---\nBody.\n";
1027 let result = set_field(content, "tags", "x");
1028 assert!(result.is_err());
1029 let err = result.unwrap_err().to_string();
1030 assert!(err.contains("flow-style"));
1031 }
1032
1033 #[test]
1034 fn set_field_rejects_multiline_scalar() {
1035 let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
1036 let result = set_field(content, "description", "new value");
1037 assert!(result.is_err());
1038 let err = result.unwrap_err().to_string();
1039 assert!(err.contains("multiline"));
1040 }
1041
1042 #[test]
1043 fn add_tag_rejects_flow_style() {
1044 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1045 let result = add_tag(content, "topic/new");
1046 assert!(result.is_err());
1047 let err = result.unwrap_err().to_string();
1048 assert!(err.contains("flow-style"));
1049 }
1050
1051 #[test]
1052 fn remove_tag_rejects_flow_style() {
1053 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1054 let result = remove_tag(content, "topic/ai");
1055 assert!(result.is_err());
1056 let err = result.unwrap_err().to_string();
1057 assert!(err.contains("flow-style"));
1058 }
1059
1060 #[test]
1061 fn atomic_create_refuses_to_overwrite_existing_file() {
1062 use std::fs;
1067 let dir = tempfile::TempDir::new().unwrap();
1068 let target = dir.path().join("note.md");
1069 fs::write(&target, "existing content\n").unwrap();
1070
1071 let err = atomic_create_with(&target, "would clobber\n", WriteOptions::default())
1072 .expect_err("atomic_create must refuse to overwrite");
1073 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1074
1075 assert_eq!(fs::read_to_string(&target).unwrap(), "existing content\n");
1077 }
1078
1079 #[test]
1080 fn atomic_create_writes_to_new_path() {
1081 use std::fs;
1082 let dir = tempfile::TempDir::new().unwrap();
1083 let target = dir.path().join("fresh.md");
1084 atomic_create_with(&target, "hello\n", WriteOptions::default()).unwrap();
1085 assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
1086 }
1087}