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_write_with(
650 path: &std::path::Path,
651 content: &str,
652 opts: WriteOptions,
653) -> std::io::Result<()> {
654 let dir = path.parent().ok_or_else(|| {
655 std::io::Error::other(format!(
656 "atomic_write target has no parent dir: {}",
657 path.display()
658 ))
659 })?;
660
661 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
666
667 use std::io::Write;
668 tmp.write_all(content.as_bytes())?;
669 tmp.flush()?;
670
671 if opts.fsync {
677 tmp.as_file().sync_all()?;
678 }
679
680 tmp.persist(path).map_err(|e| e.error)?;
684
685 if opts.fsync {
686 fsync_dir(dir)?;
687 }
688 Ok(())
689}
690
691pub fn apply(result: &WriteResult) -> std::io::Result<()> {
693 apply_with(result, WriteOptions::default())
694}
695
696pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
698 atomic_write_with(&result.path, &result.modified_content, opts)
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 const MOVIE_FILE: &str = "\
706---
707aliases:
708tags:
709 - type/leaf
710 - topic/movies
711 - source/video
712 - genre/drama
713status: to-watch
714rating:
715director: Sam Mendes
716year: 2019
717related-to:
718---
719
720Part of [[Watchlist]]
721";
722
723 const CHINESE_FILE: &str = "\
724---
725aliases:
726- kuài
727tags:
728- type/concept
729- topic/chinese
730- source/self-study
731pinyin: kuài
732anlam: hızlı
733tür: sifat
734hsk: 1
735kaliplar:
736- kalip: 快乐
737 pinyin: kuàilè
738 anlam: mutlu, neşeli
739ornekler:
740- cumle: 他跑得很快。
741 pinyin: Tā pǎo de hěn kuài.
742 anlam: O çok hızlı koşuyor.
743related-to:
744---
745
746# 快 (kuài) — hızlı
747
748Body text.
749";
750
751 #[test]
752 fn set_existing_scalar_field() {
753 let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
754 assert!(result.contains("status: watched"));
755 assert!(!result.contains("to-watch"));
756 assert!(result.contains("Part of [[Watchlist]]"));
758 match change {
759 ChangeDescription::SetField {
760 field,
761 old_value,
762 new_value,
763 } => {
764 assert_eq!(field, "status");
765 assert_eq!(old_value, "to-watch");
766 assert_eq!(new_value, "watched");
767 }
768 _ => panic!("expected SetField"),
769 }
770 }
771
772 #[test]
773 fn set_null_field() {
774 let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
775 assert!(result.contains("rating: 8"));
776 }
777
778 #[test]
779 fn set_new_field() {
780 let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
781 assert!(result.contains("language: English"));
782 let closing_idx = result.rfind("\n---\n").unwrap();
784 let lang_idx = result.find("language: English").unwrap();
785 assert!(lang_idx < closing_idx);
786 }
787
788 #[test]
789 fn set_complex_field_rejected() {
790 let result = set_field(CHINESE_FILE, "kaliplar", "something");
791 assert!(result.is_err());
792 }
793
794 #[test]
795 fn set_value_needing_quotes() {
796 let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
797 assert!(result.contains("note: 'key: value'"));
798 }
799
800 #[test]
803 fn set_field_block_inserts_new_list_as_block_yaml() {
804 let value = Value::List(vec![Value::String("kedi".into())]);
805 let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
806 assert!(result.contains("anlamlar:\n- kedi"));
809 assert!(!result.contains("anlamlar: '- kedi'"));
810 let closing_idx = result.rfind("\n---\n").unwrap();
812 assert!(result.find("anlamlar:").unwrap() < closing_idx);
813 match change {
814 ChangeDescription::SetField {
815 field, new_value, ..
816 } => {
817 assert_eq!(field, "anlamlar");
818 assert_eq!(new_value.trim_end(), "- kedi");
819 }
820 _ => panic!("expected SetField"),
821 }
822 }
823
824 #[test]
825 fn set_field_block_multi_item_list_round_trips() {
826 let value = Value::List(vec![
827 Value::String("猫が好きです。".into()),
828 Value::String("私の猫は黒いです。".into()),
829 ]);
830 let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
831 assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
832 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
834 let fm = &result[4..fm_end];
835 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
836 let items = parsed
837 .as_mapping()
838 .and_then(|m| m.get("ornekler_jp"))
839 .and_then(|v| v.as_sequence())
840 .expect("ornekler_jp must round-trip as a YAML sequence");
841 assert_eq!(items.len(), 2);
842 }
843
844 #[test]
845 fn set_field_block_replaces_existing_block_list() {
846 let value = Value::List(vec![Value::String("replaced".into())]);
849 let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
850 assert!(result.contains("kaliplar:\n- replaced"));
851 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè")); assert!(result.contains("hsk: 1"));
855 assert!(result.contains("ornekler:"));
856 }
857
858 #[test]
859 fn set_field_block_writes_map_as_nested_yaml() {
860 let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
861 m.insert("k1".into(), Value::String("v1".into()));
862 m.insert("k2".into(), Value::Integer(2));
863 let value = Value::Map(m);
864 let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
865 assert!(result.contains("meta:\n k1: v1\n k2: 2"));
866 }
867
868 #[test]
869 fn set_field_block_rejects_flow_style_existing() {
870 let content = "---\ntags: [a, b]\n---\nbody\n";
871 let value = Value::List(vec![Value::String("c".into())]);
872 let err = set_field_block(content, "tags", &value).unwrap_err();
873 let msg = format!("{}", err);
874 assert!(msg.contains("flow-style"), "got: {}", msg);
875 }
876
877 #[test]
878 fn set_field_block_rejects_scalar_value() {
879 let err =
881 set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
882 let msg = format!("{}", err);
883 assert!(msg.contains("scalar value"), "got: {}", msg);
884 }
885
886 #[test]
887 fn unset_scalar_field() {
888 let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
889 assert!(!result.contains("director:"));
890 assert!(result.contains("status: to-watch"));
892 assert!(result.contains("year: 2019"));
893 assert!(result.contains("Part of [[Watchlist]]"));
894 }
895
896 #[test]
897 fn unset_list_field() {
898 let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
899 assert!(!result.contains("kaliplar:"));
900 assert!(!result.contains("快乐"));
901 assert!(result.contains("pinyin: kuài"));
903 assert!(result.contains("Body text."));
904 }
905
906 #[test]
907 fn unset_nonexistent_field() {
908 let result = unset_field(MOVIE_FILE, "nonexistent");
909 assert!(result.is_err());
910 }
911
912 #[test]
913 fn add_tag_2space_indent() {
914 let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
915 assert!(result.contains(" - genre/war"));
916 assert!(result.contains(" - type/leaf"));
918 assert!(result.contains(" - genre/drama"));
919 }
920
921 #[test]
922 fn add_tag_0indent() {
923 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
924 assert!(result.contains("- topic/hsk1"));
925 assert!(result.contains("- type/concept"));
927 assert!(result.contains("- topic/chinese"));
928 }
929
930 #[test]
931 fn remove_tag_2space_indent() {
932 let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
933 assert!(!result.contains("genre/drama"));
934 assert!(result.contains(" - type/leaf"));
936 assert!(result.contains(" - source/video"));
937 }
938
939 #[test]
940 fn remove_tag_0indent() {
941 let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
942 assert!(!result.contains("topic/chinese"));
943 assert!(result.contains("- type/concept"));
944 assert!(result.contains("- source/self-study"));
945 }
946
947 #[test]
948 fn remove_nonexistent_tag() {
949 let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
950 assert!(result.is_err());
951 }
952
953 #[test]
954 fn body_preserved_after_set() {
955 let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
956 assert!(result.ends_with("Part of [[Watchlist]]\n"));
957 }
958
959 #[test]
960 fn body_preserved_after_unset() {
961 let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
962 assert!(result.contains("# 快 (kuài) — hızlı"));
963 assert!(result.contains("Body text."));
964 }
965
966 #[test]
967 fn body_preserved_after_add_tag() {
968 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
969 assert!(result.contains("# 快 (kuài) — hızlı"));
970 }
971
972 #[test]
973 fn chinese_content_preserved() {
974 let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
975 assert!(result.contains("pinyin: kuài"));
976 assert!(result.contains("anlam: hızlı"));
977 assert!(result.contains("tür: sifat"));
978 assert!(result.contains("kalip: 快乐"));
979 assert!(result.contains("cumle: 他跑得很快。"));
980 }
981
982 #[test]
985 fn set_field_rejects_flow_style() {
986 let content = "---\ntags: [a, b, c]\n---\nBody.\n";
987 let result = set_field(content, "tags", "x");
988 assert!(result.is_err());
989 let err = result.unwrap_err().to_string();
990 assert!(err.contains("flow-style"));
991 }
992
993 #[test]
994 fn set_field_rejects_multiline_scalar() {
995 let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
996 let result = set_field(content, "description", "new value");
997 assert!(result.is_err());
998 let err = result.unwrap_err().to_string();
999 assert!(err.contains("multiline"));
1000 }
1001
1002 #[test]
1003 fn add_tag_rejects_flow_style() {
1004 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1005 let result = add_tag(content, "topic/new");
1006 assert!(result.is_err());
1007 let err = result.unwrap_err().to_string();
1008 assert!(err.contains("flow-style"));
1009 }
1010
1011 #[test]
1012 fn remove_tag_rejects_flow_style() {
1013 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1014 let result = remove_tag(content, "topic/ai");
1015 assert!(result.is_err());
1016 let err = result.unwrap_err().to_string();
1017 assert!(err.contains("flow-style"));
1018 }
1019}