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 Ok((vec!["---", "---"], content));
66 }
67
68 let close_idx = lines[1..]
70 .iter()
71 .position(|l| l.trim() == "---")
72 .map(|i| i + 1); match close_idx {
75 Some(idx) => {
76 let fm_lines = &lines[..=idx];
77 let mut byte_offset = 0;
80 for (i, line) in content.lines().enumerate() {
81 byte_offset += line.len();
82 if byte_offset < content.len() {
84 if content.as_bytes().get(byte_offset) == Some(&b'\r') {
85 byte_offset += 1; }
87 if byte_offset < content.len() {
88 byte_offset += 1; }
90 }
91 if i == idx {
92 break;
93 }
94 }
95 let body = &content[byte_offset..];
96 Ok((fm_lines.to_vec(), body))
97 }
98 None => Err(VaultdbError::NoFrontmatter("content".into())),
99 }
100}
101
102fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
105 for line in fm_lines.iter().skip(key_line_idx + 1) {
107 let trimmed = line.trim();
108
109 if trimmed == "---"
111 || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
112 {
113 break;
114 }
115
116 if trimmed.starts_with("- ") || trimmed == "-" {
117 let dash_pos = line.find('-').unwrap();
119 let prefix = &line[..dash_pos];
120 return format!("{}- ", prefix);
121 }
122 }
123 " - ".to_string()
125}
126
127fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
129 let patterns = [format!("{}:", key), format!("{} :", key)];
130 for (i, line) in fm_lines.iter().enumerate() {
131 if i == 0 || line.trim() == "---" {
132 continue; }
134 let trimmed = line.trim_start();
135 for pattern in &patterns {
136 if trimmed.starts_with(pattern) {
137 let after = &trimmed[pattern.len()..];
139 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
140 return Some(i);
141 }
142 }
143 }
144 }
145 None
146}
147
148fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
150 let key_line = fm_lines[key_line_idx];
151 let key_indent = key_line.len() - key_line.trim_start().len();
152
153 let after_colon = key_line.trim_start();
155 if let Some(colon_pos) = after_colon.find(':') {
156 let value_part = after_colon[colon_pos + 1..].trim();
157 if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
158 return 1;
160 }
161 }
162
163 let mut extent = 1;
164 for line in fm_lines.iter().skip(key_line_idx + 1) {
165 let trimmed = line.trim();
166
167 if trimmed == "---" {
169 break;
170 }
171
172 if trimmed.is_empty() {
174 break;
175 }
176
177 let line_indent = line.len() - line.trim_start().len();
178
179 if line_indent <= key_indent && !trimmed.starts_with('-') {
182 break;
183 }
184
185 if line_indent == key_indent && trimmed.starts_with('-') {
187 extent += 1;
188 continue;
189 }
190
191 if line_indent > key_indent {
193 extent += 1;
194 continue;
195 }
196
197 break;
198 }
199 extent
200}
201
202fn is_flow_style_list(line: &str) -> bool {
204 if let Some(colon_pos) = line.find(':') {
205 let value = line[colon_pos + 1..].trim();
206 value.starts_with('[') && value.ends_with(']')
207 } else {
208 false
209 }
210}
211
212fn is_multiline_scalar(line: &str) -> bool {
214 if let Some(colon_pos) = line.find(':') {
215 let value = line[colon_pos + 1..].trim();
216 value == "|"
217 || value == ">"
218 || value == "|+"
219 || value == "|-"
220 || value == ">+"
221 || value == ">-"
222 } else {
223 false
224 }
225}
226
227pub fn quote_value(value: &str) -> String {
229 yaml_quote_value(value)
230}
231
232fn yaml_quote_value(value: &str) -> String {
233 let needs_quoting = 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.contains('!')
244 || value.contains('|')
245 || value.contains('>')
246 || value.contains('%')
247 || value.contains('@')
248 || value.starts_with(' ')
249 || value.ends_with(' ')
250 || value.starts_with('-')
251 || value.starts_with('?')
252 || is_yaml_type_ambiguous_bare_scalar(value);
258
259 if needs_quoting {
260 if value.contains('\'') {
261 format!("\"{}\"", value.replace('"', "\\\""))
262 } else {
263 format!("'{}'", value)
264 }
265 } else {
266 value.to_string()
267 }
268}
269
270fn is_yaml_type_ambiguous_bare_scalar(value: &str) -> bool {
275 let lower = value.to_ascii_lowercase();
278 if matches!(
279 lower.as_str(),
280 "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
281 ) {
282 return true;
283 }
284 if !value.is_empty() && value.parse::<f64>().is_ok() {
287 return true;
288 }
289 false
292}
293
294pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
305 let quoted_value = yaml_quote_value(value);
306 set_field_with_formatted(content, key, "ed_value, value)
307}
308
309pub fn set_field_preformatted(
327 content: &str,
328 key: &str,
329 yaml_value: &str,
330) -> Result<(String, ChangeDescription)> {
331 set_field_with_formatted(content, key, yaml_value, yaml_value)
332}
333
334fn set_field_with_formatted(
341 content: &str,
342 key: &str,
343 formatted_value: &str,
344 change_value: &str,
345) -> Result<(String, ChangeDescription)> {
346 let (fm_lines, body) = split_frontmatter(content)?;
347
348 if let Some(key_idx) = find_key_line(&fm_lines, key) {
349 if is_flow_style_list(fm_lines[key_idx]) {
357 return Err(VaultdbError::InvalidFrontmatter {
358 file: String::new(),
359 reason: format!(
360 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
361 key
362 ),
363 });
364 }
365
366 if is_multiline_scalar(fm_lines[key_idx]) {
367 return Err(VaultdbError::InvalidFrontmatter {
368 file: String::new(),
369 reason: format!(
370 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
371 key
372 ),
373 });
374 }
375
376 let extent = field_extent(&fm_lines, key_idx);
377
378 let old_value = if extent == 1 {
381 let old_line = fm_lines[key_idx];
382 old_line
383 .find(':')
384 .map(|pos| old_line[pos + 1..].trim())
385 .unwrap_or("")
386 .to_string()
387 } else {
388 fm_lines[key_idx + 1..key_idx + extent]
389 .iter()
390 .map(|l| l.trim().trim_start_matches('-').trim())
391 .filter(|s| !s.is_empty())
392 .collect::<Vec<_>>()
393 .join(", ")
394 };
395
396 let new_line = format!("{}: {}", key, formatted_value);
397
398 let mut result_lines: Vec<String> = Vec::new();
401 for (i, line) in fm_lines.iter().enumerate() {
402 if i == key_idx {
403 result_lines.push(new_line.clone());
404 } else if i > key_idx && i < key_idx + extent {
405 continue; } else {
407 result_lines.push(line.to_string());
408 }
409 }
410
411 let change = ChangeDescription::SetField {
412 field: key.to_string(),
413 old_value,
414 new_value: change_value.to_string(),
415 };
416
417 Ok((reassemble(&result_lines, body, content), change))
418 } else {
419 let mut result_lines: Vec<String> = Vec::new();
421 for (i, line) in fm_lines.iter().enumerate() {
422 if i == fm_lines.len() - 1 && line.trim() == "---" {
423 result_lines.push(format!("{}: {}", key, formatted_value));
424 }
425 result_lines.push(line.to_string());
426 }
427
428 let change = ChangeDescription::SetField {
429 field: key.to_string(),
430 old_value: String::new(),
431 new_value: change_value.to_string(),
432 };
433
434 Ok((reassemble(&result_lines, body, content), change))
435 }
436}
437
438pub fn set_field_block(
453 content: &str,
454 key: &str,
455 value: &Value,
456) -> Result<(String, ChangeDescription)> {
457 if !matches!(value, Value::List(_) | Value::Map(_)) {
458 return Err(VaultdbError::InvalidFrontmatter {
459 file: String::new(),
460 reason: format!(
461 "set_field_block called with a scalar value for '{}'; use set_field instead",
462 key
463 ),
464 });
465 }
466
467 let (fm_lines, body) = split_frontmatter(content)?;
468
469 let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
473 wrapper.insert(key.to_string(), value.clone());
474 let rendered =
475 serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
476 file: String::new(),
477 reason: format!("rendering '{}' as YAML: {}", key, e),
478 })?;
479 let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
480 let new_value_summary = serde_yaml::to_string(value)
481 .map(|s| s.trim_end().to_string())
482 .unwrap_or_default();
483
484 if let Some(key_idx) = find_key_line(&fm_lines, key) {
485 if is_flow_style_list(fm_lines[key_idx]) {
486 return Err(VaultdbError::InvalidFrontmatter {
487 file: String::new(),
488 reason: format!(
489 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
490 key
491 ),
492 });
493 }
494
495 if is_multiline_scalar(fm_lines[key_idx]) {
496 return Err(VaultdbError::InvalidFrontmatter {
497 file: String::new(),
498 reason: format!(
499 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
500 key
501 ),
502 });
503 }
504
505 let extent = field_extent(&fm_lines, key_idx);
506 let old_value = if extent == 1 {
509 fm_lines[key_idx]
510 .find(':')
511 .map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
512 .unwrap_or_default()
513 } else {
514 fm_lines[key_idx..key_idx + extent].join("\n")
515 };
516
517 let mut result_lines: Vec<String> = Vec::new();
518 for line in &fm_lines[..key_idx] {
519 result_lines.push((*line).to_string());
520 }
521 result_lines.extend(new_lines.iter().cloned());
522 for line in &fm_lines[key_idx + extent..] {
523 result_lines.push((*line).to_string());
524 }
525
526 let change = ChangeDescription::SetField {
527 field: key.to_string(),
528 old_value,
529 new_value: new_value_summary,
530 };
531
532 Ok((reassemble(&result_lines, body, content), change))
533 } else {
534 let mut result_lines: Vec<String> = Vec::new();
536 for (i, line) in fm_lines.iter().enumerate() {
537 if i == fm_lines.len() - 1 && line.trim() == "---" {
538 result_lines.extend(new_lines.iter().cloned());
539 }
540 result_lines.push((*line).to_string());
541 }
542
543 let change = ChangeDescription::SetField {
544 field: key.to_string(),
545 old_value: String::new(),
546 new_value: new_value_summary,
547 };
548
549 Ok((reassemble(&result_lines, body, content), change))
550 }
551}
552
553pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
555 let (fm_lines, body) = split_frontmatter(content)?;
556
557 let key_idx =
558 find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
559 file: String::new(),
560 reason: format!("field '{}' not found", key),
561 })?;
562
563 let extent = field_extent(&fm_lines, key_idx);
564 let old_value = fm_lines[key_idx]
565 .find(':')
566 .map(|pos| fm_lines[key_idx][pos + 1..].trim())
567 .unwrap_or("")
568 .to_string();
569
570 let mut result_lines: Vec<String> = Vec::new();
571 for (i, line) in fm_lines.iter().enumerate() {
572 if i >= key_idx && i < key_idx + extent {
573 continue; }
575 result_lines.push(line.to_string());
576 }
577
578 let change = ChangeDescription::UnsetField {
579 field: key.to_string(),
580 old_value,
581 };
582
583 Ok((reassemble(&result_lines, body, content), change))
584}
585
586pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
588 let (fm_lines, body) = split_frontmatter(content)?;
589
590 let key_idx =
591 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
592 file: String::new(),
593 reason: "no 'tags' field found".into(),
594 })?;
595
596 if is_flow_style_list(fm_lines[key_idx]) {
597 return Err(VaultdbError::InvalidFrontmatter {
598 file: String::new(),
599 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
600 });
601 }
602
603 let indent_prefix = detect_list_indent(&fm_lines, key_idx);
604 let extent = field_extent(&fm_lines, key_idx);
605 let insert_after = key_idx + extent - 1; let new_tag_line = format!("{}{}", indent_prefix, tag);
608
609 let mut result_lines: Vec<String> = Vec::new();
610 for (i, line) in fm_lines.iter().enumerate() {
611 result_lines.push(line.to_string());
612 if i == insert_after {
613 result_lines.push(new_tag_line.clone());
614 }
615 }
616
617 let change = ChangeDescription::AddTag {
618 tag: tag.to_string(),
619 };
620
621 Ok((reassemble(&result_lines, body, content), change))
622}
623
624pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
626 let (fm_lines, body) = split_frontmatter(content)?;
627
628 let key_idx =
629 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
630 file: String::new(),
631 reason: "no 'tags' field found".into(),
632 })?;
633
634 if is_flow_style_list(fm_lines[key_idx]) {
635 return Err(VaultdbError::InvalidFrontmatter {
636 file: String::new(),
637 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
638 });
639 }
640
641 let extent = field_extent(&fm_lines, key_idx);
642
643 let tag_line_idx = fm_lines
645 .iter()
646 .enumerate()
647 .skip(key_idx + 1)
648 .take(extent.saturating_sub(1))
649 .find_map(|(i, line)| {
650 let trimmed = line.trim();
651 let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
652 (tag_value == tag).then_some(i)
653 });
654
655 let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
656 file: String::new(),
657 reason: format!("tag '{}' not found in tags list", tag),
658 })?;
659
660 let mut result_lines: Vec<String> = Vec::new();
661 for (i, line) in fm_lines.iter().enumerate() {
662 if i == tag_line_idx {
663 continue;
664 }
665 result_lines.push(line.to_string());
666 }
667
668 let change = ChangeDescription::RemoveTag {
669 tag: tag.to_string(),
670 };
671
672 Ok((reassemble(&result_lines, body, content), change))
673}
674
675fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
677 let line_ending = if original.contains("\r\n") {
678 "\r\n"
679 } else {
680 "\n"
681 };
682
683 let mut result = fm_lines.join(line_ending);
684 result.push_str(line_ending);
685 result.push_str(body);
686 result
687}
688
689#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
700pub struct WriteOptions {
701 pub fsync: bool,
708}
709
710impl WriteOptions {
711 pub fn durable() -> Self {
713 Self { fsync: true }
714 }
715}
716
717pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
721 let f = std::fs::File::open(dir)?;
722 f.sync_all()
723}
724
725pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
729 atomic_write_with(path, content, WriteOptions::default())
730}
731
732pub fn atomic_create_with(
744 path: &std::path::Path,
745 content: &str,
746 opts: WriteOptions,
747) -> std::io::Result<()> {
748 let dir = path.parent().ok_or_else(|| {
749 std::io::Error::other(format!(
750 "atomic_create target has no parent dir: {}",
751 path.display()
752 ))
753 })?;
754
755 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
756 use std::io::Write;
757 tmp.write_all(content.as_bytes())?;
758 tmp.flush()?;
759
760 if opts.fsync {
761 tmp.as_file().sync_all()?;
762 }
763
764 tmp.persist_noclobber(path).map_err(|e| e.error)?;
765
766 if opts.fsync {
767 fsync_dir(dir)?;
768 }
769 Ok(())
770}
771
772pub fn atomic_write_with(
786 path: &std::path::Path,
787 content: &str,
788 opts: WriteOptions,
789) -> std::io::Result<()> {
790 let dir = path.parent().ok_or_else(|| {
791 std::io::Error::other(format!(
792 "atomic_write target has no parent dir: {}",
793 path.display()
794 ))
795 })?;
796
797 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
802
803 use std::io::Write;
804 tmp.write_all(content.as_bytes())?;
805 tmp.flush()?;
806
807 if opts.fsync {
813 tmp.as_file().sync_all()?;
814 }
815
816 tmp.persist(path).map_err(|e| e.error)?;
820
821 if opts.fsync {
822 fsync_dir(dir)?;
823 }
824 Ok(())
825}
826
827pub fn apply(result: &WriteResult) -> std::io::Result<()> {
829 apply_with(result, WriteOptions::default())
830}
831
832pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
834 atomic_write_with(&result.path, &result.modified_content, opts)
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 const MOVIE_FILE: &str = "\
842---
843aliases:
844tags:
845 - type/leaf
846 - topic/movies
847 - source/video
848 - genre/drama
849status: to-watch
850rating:
851director: Sam Mendes
852year: 2019
853related-to:
854---
855
856Part of [[Watchlist]]
857";
858
859 const CHINESE_FILE: &str = "\
860---
861aliases:
862- kuài
863tags:
864- type/concept
865- topic/chinese
866- source/self-study
867pinyin: kuài
868anlam: hızlı
869tür: sifat
870hsk: 1
871kaliplar:
872- kalip: 快乐
873 pinyin: kuàilè
874 anlam: mutlu, neşeli
875ornekler:
876- cumle: 他跑得很快。
877 pinyin: Tā pǎo de hěn kuài.
878 anlam: O çok hızlı koşuyor.
879related-to:
880---
881
882# 快 (kuài) — hızlı
883
884Body text.
885";
886
887 #[test]
888 fn set_existing_scalar_field() {
889 let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
890 assert!(result.contains("status: watched"));
891 assert!(!result.contains("to-watch"));
892 assert!(result.contains("Part of [[Watchlist]]"));
894 match change {
895 ChangeDescription::SetField {
896 field,
897 old_value,
898 new_value,
899 } => {
900 assert_eq!(field, "status");
901 assert_eq!(old_value, "to-watch");
902 assert_eq!(new_value, "watched");
903 }
904 _ => panic!("expected SetField"),
905 }
906 }
907
908 #[test]
909 fn set_null_field() {
910 let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
919 assert!(result.contains("rating: '8'"), "got:\n{}", result);
920 }
921
922 #[test]
923 fn set_new_field() {
924 let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
925 assert!(result.contains("language: English"));
926 let closing_idx = result.rfind("\n---\n").unwrap();
928 let lang_idx = result.find("language: English").unwrap();
929 assert!(lang_idx < closing_idx);
930 }
931
932 #[test]
933 fn set_scalar_over_block_field_replaces() {
934 let (result, change) = set_field(CHINESE_FILE, "kaliplar", "something").unwrap();
938 assert!(result.contains("kaliplar: something"), "got:\n{}", result);
939 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè"));
941 assert!(result.contains("hsk: 1"));
943 assert!(result.contains("ornekler:"));
944 assert!(result.contains("Body text."));
945 match change {
946 ChangeDescription::SetField {
947 field, new_value, ..
948 } => {
949 assert_eq!(field, "kaliplar");
950 assert_eq!(new_value, "something");
951 }
952 _ => panic!("expected SetField"),
953 }
954 }
955
956 #[test]
957 fn set_field_initializes_frontmatter_on_bare_file() {
958 let bare = "# Just a heading\n\nSome body text.\n";
961 let (result, _) = set_field(bare, "db-table", "rusen-wiki").unwrap();
962 assert!(result.starts_with("---\n"), "got:\n{}", result);
963 assert!(result.contains("db-table: rusen-wiki"));
964 assert!(result.contains("# Just a heading"));
966 assert!(result.contains("Some body text."));
967 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
969 let fm = &result[4..fm_end];
970 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
971 assert_eq!(
972 parsed
973 .as_mapping()
974 .and_then(|m| m.get("db-table"))
975 .and_then(|v| v.as_str()),
976 Some("rusen-wiki")
977 );
978 }
979
980 #[test]
981 fn set_value_needing_quotes() {
982 let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
983 assert!(result.contains("note: 'key: value'"));
984 }
985
986 #[test]
989 fn set_field_block_inserts_new_list_as_block_yaml() {
990 let value = Value::List(vec![Value::String("kedi".into())]);
991 let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
992 assert!(result.contains("anlamlar:\n- kedi"));
995 assert!(!result.contains("anlamlar: '- kedi'"));
996 let closing_idx = result.rfind("\n---\n").unwrap();
998 assert!(result.find("anlamlar:").unwrap() < closing_idx);
999 match change {
1000 ChangeDescription::SetField {
1001 field, new_value, ..
1002 } => {
1003 assert_eq!(field, "anlamlar");
1004 assert_eq!(new_value.trim_end(), "- kedi");
1005 }
1006 _ => panic!("expected SetField"),
1007 }
1008 }
1009
1010 #[test]
1011 fn set_field_block_multi_item_list_round_trips() {
1012 let value = Value::List(vec![
1013 Value::String("猫が好きです。".into()),
1014 Value::String("私の猫は黒いです。".into()),
1015 ]);
1016 let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
1017 assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
1018 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
1020 let fm = &result[4..fm_end];
1021 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
1022 let items = parsed
1023 .as_mapping()
1024 .and_then(|m| m.get("ornekler_jp"))
1025 .and_then(|v| v.as_sequence())
1026 .expect("ornekler_jp must round-trip as a YAML sequence");
1027 assert_eq!(items.len(), 2);
1028 }
1029
1030 #[test]
1031 fn set_field_block_replaces_existing_block_list() {
1032 let value = Value::List(vec![Value::String("replaced".into())]);
1035 let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
1036 assert!(result.contains("kaliplar:\n- replaced"));
1037 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè")); assert!(result.contains("hsk: 1"));
1041 assert!(result.contains("ornekler:"));
1042 }
1043
1044 #[test]
1045 fn set_field_block_writes_map_as_nested_yaml() {
1046 let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
1047 m.insert("k1".into(), Value::String("v1".into()));
1048 m.insert("k2".into(), Value::Integer(2));
1049 let value = Value::Map(m);
1050 let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
1051 assert!(result.contains("meta:\n k1: v1\n k2: 2"));
1052 }
1053
1054 #[test]
1055 fn set_field_block_rejects_flow_style_existing() {
1056 let content = "---\ntags: [a, b]\n---\nbody\n";
1057 let value = Value::List(vec![Value::String("c".into())]);
1058 let err = set_field_block(content, "tags", &value).unwrap_err();
1059 let msg = format!("{}", err);
1060 assert!(msg.contains("flow-style"), "got: {}", msg);
1061 }
1062
1063 #[test]
1064 fn set_field_block_rejects_scalar_value() {
1065 let err =
1067 set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
1068 let msg = format!("{}", err);
1069 assert!(msg.contains("scalar value"), "got: {}", msg);
1070 }
1071
1072 #[test]
1073 fn unset_scalar_field() {
1074 let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
1075 assert!(!result.contains("director:"));
1076 assert!(result.contains("status: to-watch"));
1078 assert!(result.contains("year: 2019"));
1079 assert!(result.contains("Part of [[Watchlist]]"));
1080 }
1081
1082 #[test]
1083 fn unset_list_field() {
1084 let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
1085 assert!(!result.contains("kaliplar:"));
1086 assert!(!result.contains("快乐"));
1087 assert!(result.contains("pinyin: kuài"));
1089 assert!(result.contains("Body text."));
1090 }
1091
1092 #[test]
1093 fn unset_nonexistent_field() {
1094 let result = unset_field(MOVIE_FILE, "nonexistent");
1095 assert!(result.is_err());
1096 }
1097
1098 #[test]
1099 fn add_tag_2space_indent() {
1100 let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
1101 assert!(result.contains(" - genre/war"));
1102 assert!(result.contains(" - type/leaf"));
1104 assert!(result.contains(" - genre/drama"));
1105 }
1106
1107 #[test]
1108 fn add_tag_0indent() {
1109 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1110 assert!(result.contains("- topic/hsk1"));
1111 assert!(result.contains("- type/concept"));
1113 assert!(result.contains("- topic/chinese"));
1114 }
1115
1116 #[test]
1117 fn remove_tag_2space_indent() {
1118 let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
1119 assert!(!result.contains("genre/drama"));
1120 assert!(result.contains(" - type/leaf"));
1122 assert!(result.contains(" - source/video"));
1123 }
1124
1125 #[test]
1126 fn remove_tag_0indent() {
1127 let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
1128 assert!(!result.contains("topic/chinese"));
1129 assert!(result.contains("- type/concept"));
1130 assert!(result.contains("- source/self-study"));
1131 }
1132
1133 #[test]
1134 fn remove_nonexistent_tag() {
1135 let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
1136 assert!(result.is_err());
1137 }
1138
1139 #[test]
1140 fn body_preserved_after_set() {
1141 let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
1142 assert!(result.ends_with("Part of [[Watchlist]]\n"));
1143 }
1144
1145 #[test]
1146 fn body_preserved_after_unset() {
1147 let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
1148 assert!(result.contains("# 快 (kuài) — hızlı"));
1149 assert!(result.contains("Body text."));
1150 }
1151
1152 #[test]
1153 fn body_preserved_after_add_tag() {
1154 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1155 assert!(result.contains("# 快 (kuài) — hızlı"));
1156 }
1157
1158 #[test]
1159 fn chinese_content_preserved() {
1160 let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
1161 assert!(result.contains("pinyin: kuài"));
1162 assert!(result.contains("anlam: hızlı"));
1163 assert!(result.contains("tür: sifat"));
1164 assert!(result.contains("kalip: 快乐"));
1165 assert!(result.contains("cumle: 他跑得很快。"));
1166 }
1167
1168 #[test]
1171 fn set_field_rejects_flow_style() {
1172 let content = "---\ntags: [a, b, c]\n---\nBody.\n";
1173 let result = set_field(content, "tags", "x");
1174 assert!(result.is_err());
1175 let err = result.unwrap_err().to_string();
1176 assert!(err.contains("flow-style"));
1177 }
1178
1179 #[test]
1180 fn set_field_rejects_multiline_scalar() {
1181 let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
1182 let result = set_field(content, "description", "new value");
1183 assert!(result.is_err());
1184 let err = result.unwrap_err().to_string();
1185 assert!(err.contains("multiline"));
1186 }
1187
1188 #[test]
1189 fn add_tag_rejects_flow_style() {
1190 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1191 let result = add_tag(content, "topic/new");
1192 assert!(result.is_err());
1193 let err = result.unwrap_err().to_string();
1194 assert!(err.contains("flow-style"));
1195 }
1196
1197 #[test]
1198 fn remove_tag_rejects_flow_style() {
1199 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1200 let result = remove_tag(content, "topic/ai");
1201 assert!(result.is_err());
1202 let err = result.unwrap_err().to_string();
1203 assert!(err.contains("flow-style"));
1204 }
1205
1206 #[test]
1207 fn atomic_create_refuses_to_overwrite_existing_file() {
1208 use std::fs;
1213 let dir = tempfile::TempDir::new().unwrap();
1214 let target = dir.path().join("note.md");
1215 fs::write(&target, "existing content\n").unwrap();
1216
1217 let err = atomic_create_with(&target, "would clobber\n", WriteOptions::default())
1218 .expect_err("atomic_create must refuse to overwrite");
1219 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1220
1221 assert_eq!(fs::read_to_string(&target).unwrap(), "existing content\n");
1223 }
1224
1225 #[test]
1226 fn atomic_create_writes_to_new_path() {
1227 use std::fs;
1228 let dir = tempfile::TempDir::new().unwrap();
1229 let target = dir.path().join("fresh.md");
1230 atomic_create_with(&target, "hello\n", WriteOptions::default()).unwrap();
1231 assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
1232 }
1233
1234 #[test]
1235 fn set_field_preformatted_writes_value_verbatim() {
1236 let content = "---\nurl:\n---\nBody\n";
1241 let preformatted = "'https://www.amazon.com.tr/foo'";
1242 let (out, _) = set_field_preformatted(content, "url", preformatted).unwrap();
1243 assert!(
1244 out.contains("url: 'https://www.amazon.com.tr/foo'"),
1245 "got:\n{}",
1246 out
1247 );
1248 assert!(
1249 !out.contains("url: \"'"),
1250 "preformatted value was double-quoted; got:\n{}",
1251 out
1252 );
1253 }
1254
1255 #[test]
1256 fn set_field_still_quotes_raw_values() {
1257 let content = "---\nurl:\n---\n";
1260 let (out, _) = set_field(content, "url", "https://www.example.com").unwrap();
1261 assert!(
1262 out.contains("url: 'https://www.example.com'"),
1263 "got:\n{}",
1264 out
1265 );
1266 assert!(!out.contains("url: \"'"), "got:\n{}", out);
1268 }
1269}