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 || 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
265fn is_yaml_type_ambiguous_bare_scalar(value: &str) -> bool {
270 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 if !value.is_empty() && value.parse::<f64>().is_ok() {
282 return true;
283 }
284 false
287}
288
289pub 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, "ed_value, value)
302}
303
304pub 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
329fn 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 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
420pub 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 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 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 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
535pub 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; }
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
568pub 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; 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
606pub 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 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
657fn 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
682pub struct WriteOptions {
683 pub fsync: bool,
690}
691
692impl WriteOptions {
693 pub fn durable() -> Self {
695 Self { fsync: true }
696 }
697}
698
699pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
703 let f = std::fs::File::open(dir)?;
704 f.sync_all()
705}
706
707pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
711 atomic_write_with(path, content, WriteOptions::default())
712}
713
714pub 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
754pub 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 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 if opts.fsync {
795 tmp.as_file().sync_all()?;
796 }
797
798 tmp.persist(path).map_err(|e| e.error)?;
802
803 if opts.fsync {
804 fsync_dir(dir)?;
805 }
806 Ok(())
807}
808
809pub fn apply(result: &WriteResult) -> std::io::Result<()> {
811 apply_with(result, WriteOptions::default())
812}
813
814pub 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 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 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 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 #[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 assert!(result.contains("anlamlar:\n- kedi"));
935 assert!(!result.contains("anlamlar: '- kedi'"));
936 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 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 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("快乐")); assert!(!result.contains("kuàilè")); 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 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 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 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 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 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 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 #[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 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 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 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 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 assert!(!out.contains("url: \"'"), "got:\n{}", out);
1208 }
1209}