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 SetBody {
32 old_len: usize,
33 new_len: usize,
34 },
35 AppendBody {
39 added_len: usize,
40 },
41 ClearBody {
44 old_len: usize,
45 },
46}
47
48impl std::fmt::Display for ChangeDescription {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 ChangeDescription::SetField {
52 field,
53 old_value,
54 new_value,
55 } => write!(f, "set {} = {} (was: {})", field, new_value, old_value),
56 ChangeDescription::UnsetField { field, old_value } => {
57 write!(f, "unset {} (was: {})", field, old_value)
58 }
59 ChangeDescription::AddTag { tag } => write!(f, "add tag: {}", tag),
60 ChangeDescription::RemoveTag { tag } => write!(f, "remove tag: {}", tag),
61 ChangeDescription::SetBody { old_len, new_len } => {
62 write!(f, "set body ({} → {} bytes)", old_len, new_len)
63 }
64 ChangeDescription::AppendBody { added_len } => {
65 write!(f, "append body (+{} bytes)", added_len)
66 }
67 ChangeDescription::ClearBody { old_len } => {
68 write!(f, "clear body (was {} bytes)", old_len)
69 }
70 }
71 }
72}
73
74pub struct WriteResult {
76 pub path: std::path::PathBuf,
77 pub original_content: String,
78 pub modified_content: String,
79 pub changes: Vec<ChangeDescription>,
80}
81
82fn split_frontmatter(content: &str) -> Result<(Vec<&str>, &str)> {
85 let lines: Vec<&str> = content.lines().collect();
86
87 if lines.is_empty() || lines[0].trim() != "---" {
88 return Ok((vec!["---", "---"], content));
94 }
95
96 let close_idx = lines[1..]
98 .iter()
99 .position(|l| l.trim() == "---")
100 .map(|i| i + 1); match close_idx {
103 Some(idx) => {
104 let fm_lines = &lines[..=idx];
105 let mut byte_offset = 0;
108 for (i, line) in content.lines().enumerate() {
109 byte_offset += line.len();
110 if byte_offset < content.len() {
112 if content.as_bytes().get(byte_offset) == Some(&b'\r') {
113 byte_offset += 1; }
115 if byte_offset < content.len() {
116 byte_offset += 1; }
118 }
119 if i == idx {
120 break;
121 }
122 }
123 let body = &content[byte_offset..];
124 Ok((fm_lines.to_vec(), body))
125 }
126 None => Err(VaultdbError::NoFrontmatter("content".into())),
127 }
128}
129
130fn detect_list_indent(fm_lines: &[&str], key_line_idx: usize) -> String {
133 for line in fm_lines.iter().skip(key_line_idx + 1) {
135 let trimmed = line.trim();
136
137 if trimmed == "---"
139 || (!line.starts_with(' ') && !line.starts_with('-') && trimmed.contains(':'))
140 {
141 break;
142 }
143
144 if trimmed.starts_with("- ") || trimmed == "-" {
145 let dash_pos = line.find('-').unwrap();
147 let prefix = &line[..dash_pos];
148 return format!("{}- ", prefix);
149 }
150 }
151 " - ".to_string()
153}
154
155fn find_key_line(fm_lines: &[&str], key: &str) -> Option<usize> {
157 let patterns = [format!("{}:", key), format!("{} :", key)];
158 for (i, line) in fm_lines.iter().enumerate() {
159 if i == 0 || line.trim() == "---" {
160 continue; }
162 let trimmed = line.trim_start();
163 for pattern in &patterns {
164 if trimmed.starts_with(pattern) {
165 let after = &trimmed[pattern.len()..];
167 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
168 return Some(i);
169 }
170 }
171 }
172 }
173 None
174}
175
176fn field_extent(fm_lines: &[&str], key_line_idx: usize) -> usize {
178 let key_line = fm_lines[key_line_idx];
179 let key_indent = key_line.len() - key_line.trim_start().len();
180
181 let after_colon = key_line.trim_start();
183 if let Some(colon_pos) = after_colon.find(':') {
184 let value_part = after_colon[colon_pos + 1..].trim();
185 if !value_part.is_empty() && !value_part.starts_with('[') && !value_part.starts_with('{') {
186 return 1;
188 }
189 }
190
191 let mut extent = 1;
192 for line in fm_lines.iter().skip(key_line_idx + 1) {
193 let trimmed = line.trim();
194
195 if trimmed == "---" {
197 break;
198 }
199
200 if trimmed.is_empty() {
202 break;
203 }
204
205 let line_indent = line.len() - line.trim_start().len();
206
207 if line_indent <= key_indent && !trimmed.starts_with('-') {
210 break;
211 }
212
213 if line_indent == key_indent && trimmed.starts_with('-') {
215 extent += 1;
216 continue;
217 }
218
219 if line_indent > key_indent {
221 extent += 1;
222 continue;
223 }
224
225 break;
226 }
227 extent
228}
229
230fn is_flow_style_list(line: &str) -> bool {
232 if let Some(colon_pos) = line.find(':') {
233 let value = line[colon_pos + 1..].trim();
234 value.starts_with('[') && value.ends_with(']')
235 } else {
236 false
237 }
238}
239
240fn is_multiline_scalar(line: &str) -> bool {
242 if let Some(colon_pos) = line.find(':') {
243 let value = line[colon_pos + 1..].trim();
244 value == "|"
245 || value == ">"
246 || value == "|+"
247 || value == "|-"
248 || value == ">+"
249 || value == ">-"
250 } else {
251 false
252 }
253}
254
255pub fn quote_value(value: &str) -> String {
257 yaml_quote_value(value)
258}
259
260fn yaml_quote_value(value: &str) -> String {
261 let needs_quoting = value.contains(':')
262 || value.contains('#')
263 || value.contains('[')
264 || value.contains(']')
265 || value.contains('{')
266 || value.contains('}')
267 || value.contains('\'')
268 || value.contains('"')
269 || value.contains('&')
270 || value.contains('*')
271 || value.contains('!')
272 || value.contains('|')
273 || value.contains('>')
274 || value.contains('%')
275 || value.contains('@')
276 || value.starts_with(' ')
277 || value.ends_with(' ')
278 || value.starts_with('-')
279 || value.starts_with('?')
280 || is_yaml_type_ambiguous_bare_scalar(value);
286
287 if needs_quoting {
288 if value.contains('\'') {
289 format!("\"{}\"", value.replace('"', "\\\""))
290 } else {
291 format!("'{}'", value)
292 }
293 } else {
294 value.to_string()
295 }
296}
297
298fn is_yaml_type_ambiguous_bare_scalar(value: &str) -> bool {
303 let lower = value.to_ascii_lowercase();
306 if matches!(
307 lower.as_str(),
308 "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~"
309 ) {
310 return true;
311 }
312 if !value.is_empty() && value.parse::<f64>().is_ok() {
315 return true;
316 }
317 false
320}
321
322pub fn set_field(content: &str, key: &str, value: &str) -> Result<(String, ChangeDescription)> {
333 let quoted_value = yaml_quote_value(value);
334 set_field_with_formatted(content, key, "ed_value, value)
335}
336
337pub fn set_field_preformatted(
355 content: &str,
356 key: &str,
357 yaml_value: &str,
358) -> Result<(String, ChangeDescription)> {
359 set_field_with_formatted(content, key, yaml_value, yaml_value)
360}
361
362fn set_field_with_formatted(
369 content: &str,
370 key: &str,
371 formatted_value: &str,
372 change_value: &str,
373) -> Result<(String, ChangeDescription)> {
374 let (fm_lines, body) = split_frontmatter(content)?;
375
376 if let Some(key_idx) = find_key_line(&fm_lines, key) {
377 if is_flow_style_list(fm_lines[key_idx]) {
385 return Err(VaultdbError::InvalidFrontmatter {
386 file: String::new(),
387 reason: format!(
388 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
389 key
390 ),
391 });
392 }
393
394 if is_multiline_scalar(fm_lines[key_idx]) {
395 return Err(VaultdbError::InvalidFrontmatter {
396 file: String::new(),
397 reason: format!(
398 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
399 key
400 ),
401 });
402 }
403
404 let extent = field_extent(&fm_lines, key_idx);
405
406 let old_value = if extent == 1 {
409 let old_line = fm_lines[key_idx];
410 old_line
411 .find(':')
412 .map(|pos| old_line[pos + 1..].trim())
413 .unwrap_or("")
414 .to_string()
415 } else {
416 fm_lines[key_idx + 1..key_idx + extent]
417 .iter()
418 .map(|l| l.trim().trim_start_matches('-').trim())
419 .filter(|s| !s.is_empty())
420 .collect::<Vec<_>>()
421 .join(", ")
422 };
423
424 let new_line = format!("{}: {}", key, formatted_value);
425
426 let mut result_lines: Vec<String> = Vec::new();
429 for (i, line) in fm_lines.iter().enumerate() {
430 if i == key_idx {
431 result_lines.push(new_line.clone());
432 } else if i > key_idx && i < key_idx + extent {
433 continue; } else {
435 result_lines.push(line.to_string());
436 }
437 }
438
439 let change = ChangeDescription::SetField {
440 field: key.to_string(),
441 old_value,
442 new_value: change_value.to_string(),
443 };
444
445 Ok((reassemble(&result_lines, body, content), change))
446 } else {
447 let mut result_lines: Vec<String> = Vec::new();
449 for (i, line) in fm_lines.iter().enumerate() {
450 if i == fm_lines.len() - 1 && line.trim() == "---" {
451 result_lines.push(format!("{}: {}", key, formatted_value));
452 }
453 result_lines.push(line.to_string());
454 }
455
456 let change = ChangeDescription::SetField {
457 field: key.to_string(),
458 old_value: String::new(),
459 new_value: change_value.to_string(),
460 };
461
462 Ok((reassemble(&result_lines, body, content), change))
463 }
464}
465
466pub fn set_field_block(
481 content: &str,
482 key: &str,
483 value: &Value,
484) -> Result<(String, ChangeDescription)> {
485 if !matches!(value, Value::List(_) | Value::Map(_)) {
486 return Err(VaultdbError::InvalidFrontmatter {
487 file: String::new(),
488 reason: format!(
489 "set_field_block called with a scalar value for '{}'; use set_field instead",
490 key
491 ),
492 });
493 }
494
495 let (fm_lines, body) = split_frontmatter(content)?;
496
497 let mut wrapper: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
501 wrapper.insert(key.to_string(), value.clone());
502 let rendered =
503 serde_yaml::to_string(&wrapper).map_err(|e| VaultdbError::InvalidFrontmatter {
504 file: String::new(),
505 reason: format!("rendering '{}' as YAML: {}", key, e),
506 })?;
507 let new_lines: Vec<String> = rendered.lines().map(String::from).collect();
508 let new_value_summary = serde_yaml::to_string(value)
509 .map(|s| s.trim_end().to_string())
510 .unwrap_or_default();
511
512 if let Some(key_idx) = find_key_line(&fm_lines, key) {
513 if is_flow_style_list(fm_lines[key_idx]) {
514 return Err(VaultdbError::InvalidFrontmatter {
515 file: String::new(),
516 reason: format!(
517 "field '{}' uses flow-style YAML (e.g., [a, b]). Use --unset first, then re-add.",
518 key
519 ),
520 });
521 }
522
523 if is_multiline_scalar(fm_lines[key_idx]) {
524 return Err(VaultdbError::InvalidFrontmatter {
525 file: String::new(),
526 reason: format!(
527 "field '{}' uses a multiline scalar (| or >). Use --unset first, then re-add.",
528 key
529 ),
530 });
531 }
532
533 let extent = field_extent(&fm_lines, key_idx);
534 let old_value = if extent == 1 {
537 fm_lines[key_idx]
538 .find(':')
539 .map(|pos| fm_lines[key_idx][pos + 1..].trim().to_string())
540 .unwrap_or_default()
541 } else {
542 fm_lines[key_idx..key_idx + extent].join("\n")
543 };
544
545 let mut result_lines: Vec<String> = Vec::new();
546 for line in &fm_lines[..key_idx] {
547 result_lines.push((*line).to_string());
548 }
549 result_lines.extend(new_lines.iter().cloned());
550 for line in &fm_lines[key_idx + extent..] {
551 result_lines.push((*line).to_string());
552 }
553
554 let change = ChangeDescription::SetField {
555 field: key.to_string(),
556 old_value,
557 new_value: new_value_summary,
558 };
559
560 Ok((reassemble(&result_lines, body, content), change))
561 } else {
562 let mut result_lines: Vec<String> = Vec::new();
564 for (i, line) in fm_lines.iter().enumerate() {
565 if i == fm_lines.len() - 1 && line.trim() == "---" {
566 result_lines.extend(new_lines.iter().cloned());
567 }
568 result_lines.push((*line).to_string());
569 }
570
571 let change = ChangeDescription::SetField {
572 field: key.to_string(),
573 old_value: String::new(),
574 new_value: new_value_summary,
575 };
576
577 Ok((reassemble(&result_lines, body, content), change))
578 }
579}
580
581pub fn unset_field(content: &str, key: &str) -> Result<(String, ChangeDescription)> {
583 let (fm_lines, body) = split_frontmatter(content)?;
584
585 let key_idx =
586 find_key_line(&fm_lines, key).ok_or_else(|| VaultdbError::InvalidFrontmatter {
587 file: String::new(),
588 reason: format!("field '{}' not found", key),
589 })?;
590
591 let extent = field_extent(&fm_lines, key_idx);
592 let old_value = fm_lines[key_idx]
593 .find(':')
594 .map(|pos| fm_lines[key_idx][pos + 1..].trim())
595 .unwrap_or("")
596 .to_string();
597
598 let mut result_lines: Vec<String> = Vec::new();
599 for (i, line) in fm_lines.iter().enumerate() {
600 if i >= key_idx && i < key_idx + extent {
601 continue; }
603 result_lines.push(line.to_string());
604 }
605
606 let change = ChangeDescription::UnsetField {
607 field: key.to_string(),
608 old_value,
609 };
610
611 Ok((reassemble(&result_lines, body, content), change))
612}
613
614pub fn add_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
616 let (fm_lines, body) = split_frontmatter(content)?;
617
618 let key_idx =
619 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
620 file: String::new(),
621 reason: "no 'tags' field found".into(),
622 })?;
623
624 if is_flow_style_list(fm_lines[key_idx]) {
625 return Err(VaultdbError::InvalidFrontmatter {
626 file: String::new(),
627 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
628 });
629 }
630
631 let indent_prefix = detect_list_indent(&fm_lines, key_idx);
632 let extent = field_extent(&fm_lines, key_idx);
633 let insert_after = key_idx + extent - 1; let new_tag_line = format!("{}{}", indent_prefix, tag);
636
637 let mut result_lines: Vec<String> = Vec::new();
638 for (i, line) in fm_lines.iter().enumerate() {
639 result_lines.push(line.to_string());
640 if i == insert_after {
641 result_lines.push(new_tag_line.clone());
642 }
643 }
644
645 let change = ChangeDescription::AddTag {
646 tag: tag.to_string(),
647 };
648
649 Ok((reassemble(&result_lines, body, content), change))
650}
651
652pub fn remove_tag(content: &str, tag: &str) -> Result<(String, ChangeDescription)> {
654 let (fm_lines, body) = split_frontmatter(content)?;
655
656 let key_idx =
657 find_key_line(&fm_lines, "tags").ok_or_else(|| VaultdbError::InvalidFrontmatter {
658 file: String::new(),
659 reason: "no 'tags' field found".into(),
660 })?;
661
662 if is_flow_style_list(fm_lines[key_idx]) {
663 return Err(VaultdbError::InvalidFrontmatter {
664 file: String::new(),
665 reason: "tags field uses flow-style YAML (e.g., tags: [a, b]). Convert to block-style first.".into(),
666 });
667 }
668
669 let extent = field_extent(&fm_lines, key_idx);
670
671 let tag_line_idx = fm_lines
673 .iter()
674 .enumerate()
675 .skip(key_idx + 1)
676 .take(extent.saturating_sub(1))
677 .find_map(|(i, line)| {
678 let trimmed = line.trim();
679 let tag_value = trimmed.strip_prefix("- ").unwrap_or(trimmed);
680 (tag_value == tag).then_some(i)
681 });
682
683 let tag_line_idx = tag_line_idx.ok_or_else(|| VaultdbError::InvalidFrontmatter {
684 file: String::new(),
685 reason: format!("tag '{}' not found in tags list", tag),
686 })?;
687
688 let mut result_lines: Vec<String> = Vec::new();
689 for (i, line) in fm_lines.iter().enumerate() {
690 if i == tag_line_idx {
691 continue;
692 }
693 result_lines.push(line.to_string());
694 }
695
696 let change = ChangeDescription::RemoveTag {
697 tag: tag.to_string(),
698 };
699
700 Ok((reassemble(&result_lines, body, content), change))
701}
702
703pub fn set_body(content: &str, new_body: &str) -> Result<(String, ChangeDescription)> {
722 let (fm_lines, old_body) = split_frontmatter(content)?;
723 let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
724 let change = ChangeDescription::SetBody {
725 old_len: old_body.len(),
726 new_len: new_body.len(),
727 };
728 Ok((reassemble(&fm_owned, new_body, content), change))
729}
730
731pub fn clear_body(content: &str) -> Result<(String, ChangeDescription)> {
735 let (fm_lines, old_body) = split_frontmatter(content)?;
736 let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
737 let change = ChangeDescription::ClearBody {
738 old_len: old_body.len(),
739 };
740 Ok((reassemble(&fm_owned, "", content), change))
741}
742
743pub fn append_body(
753 content: &str,
754 text: &str,
755 separator: &str,
756) -> Result<(String, ChangeDescription)> {
757 let (fm_lines, old_body) = split_frontmatter(content)?;
758 let fm_owned: Vec<String> = fm_lines.iter().map(|s| s.to_string()).collect();
759
760 let new_body = if old_body.is_empty() {
761 text.to_string()
762 } else {
763 let trimmed = old_body.trim_end_matches(['\n', '\r']);
767 format!("{}{}{}", trimmed, separator, text)
768 };
769
770 let change = ChangeDescription::AppendBody {
771 added_len: text.len(),
772 };
773 Ok((reassemble(&fm_owned, &new_body, content), change))
774}
775
776fn reassemble(fm_lines: &[String], body: &str, original: &str) -> String {
778 let line_ending = if original.contains("\r\n") {
779 "\r\n"
780 } else {
781 "\n"
782 };
783
784 let mut result = fm_lines.join(line_ending);
785 result.push_str(line_ending);
786 result.push_str(body);
787 result
788}
789
790#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
801pub struct WriteOptions {
802 pub fsync: bool,
809}
810
811impl WriteOptions {
812 pub fn durable() -> Self {
814 Self { fsync: true }
815 }
816}
817
818pub fn fsync_dir(dir: &std::path::Path) -> std::io::Result<()> {
822 let f = std::fs::File::open(dir)?;
823 f.sync_all()
824}
825
826pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
830 atomic_write_with(path, content, WriteOptions::default())
831}
832
833pub fn atomic_create_with(
845 path: &std::path::Path,
846 content: &str,
847 opts: WriteOptions,
848) -> std::io::Result<()> {
849 let dir = path.parent().ok_or_else(|| {
850 std::io::Error::other(format!(
851 "atomic_create target has no parent dir: {}",
852 path.display()
853 ))
854 })?;
855
856 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
857 use std::io::Write;
858 tmp.write_all(content.as_bytes())?;
859 tmp.flush()?;
860
861 if opts.fsync {
862 tmp.as_file().sync_all()?;
863 }
864
865 tmp.persist_noclobber(path).map_err(|e| e.error)?;
866
867 if opts.fsync {
868 fsync_dir(dir)?;
869 }
870 Ok(())
871}
872
873pub fn atomic_write_with(
887 path: &std::path::Path,
888 content: &str,
889 opts: WriteOptions,
890) -> std::io::Result<()> {
891 let dir = path.parent().ok_or_else(|| {
892 std::io::Error::other(format!(
893 "atomic_write target has no parent dir: {}",
894 path.display()
895 ))
896 })?;
897
898 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
903
904 use std::io::Write;
905 tmp.write_all(content.as_bytes())?;
906 tmp.flush()?;
907
908 if opts.fsync {
914 tmp.as_file().sync_all()?;
915 }
916
917 tmp.persist(path).map_err(|e| e.error)?;
921
922 if opts.fsync {
923 fsync_dir(dir)?;
924 }
925 Ok(())
926}
927
928pub fn apply(result: &WriteResult) -> std::io::Result<()> {
930 apply_with(result, WriteOptions::default())
931}
932
933pub fn apply_with(result: &WriteResult, opts: WriteOptions) -> std::io::Result<()> {
935 atomic_write_with(&result.path, &result.modified_content, opts)
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 const MOVIE_FILE: &str = "\
943---
944aliases:
945tags:
946 - type/leaf
947 - topic/movies
948 - source/video
949 - genre/drama
950status: to-watch
951rating:
952director: Sam Mendes
953year: 2019
954related-to:
955---
956
957Part of [[Watchlist]]
958";
959
960 const CHINESE_FILE: &str = "\
961---
962aliases:
963- kuài
964tags:
965- type/concept
966- topic/chinese
967- source/self-study
968pinyin: kuài
969anlam: hızlı
970tür: sifat
971hsk: 1
972kaliplar:
973- kalip: 快乐
974 pinyin: kuàilè
975 anlam: mutlu, neşeli
976ornekler:
977- cumle: 他跑得很快。
978 pinyin: Tā pǎo de hěn kuài.
979 anlam: O çok hızlı koşuyor.
980related-to:
981---
982
983# 快 (kuài) — hızlı
984
985Body text.
986";
987
988 #[test]
989 fn set_existing_scalar_field() {
990 let (result, change) = set_field(MOVIE_FILE, "status", "watched").unwrap();
991 assert!(result.contains("status: watched"));
992 assert!(!result.contains("to-watch"));
993 assert!(result.contains("Part of [[Watchlist]]"));
995 match change {
996 ChangeDescription::SetField {
997 field,
998 old_value,
999 new_value,
1000 } => {
1001 assert_eq!(field, "status");
1002 assert_eq!(old_value, "to-watch");
1003 assert_eq!(new_value, "watched");
1004 }
1005 _ => panic!("expected SetField"),
1006 }
1007 }
1008
1009 #[test]
1010 fn set_null_field() {
1011 let (result, _) = set_field(MOVIE_FILE, "rating", "8").unwrap();
1020 assert!(result.contains("rating: '8'"), "got:\n{}", result);
1021 }
1022
1023 #[test]
1024 fn set_new_field() {
1025 let (result, _) = set_field(MOVIE_FILE, "language", "English").unwrap();
1026 assert!(result.contains("language: English"));
1027 let closing_idx = result.rfind("\n---\n").unwrap();
1029 let lang_idx = result.find("language: English").unwrap();
1030 assert!(lang_idx < closing_idx);
1031 }
1032
1033 #[test]
1034 fn set_scalar_over_block_field_replaces() {
1035 let (result, change) = set_field(CHINESE_FILE, "kaliplar", "something").unwrap();
1039 assert!(result.contains("kaliplar: something"), "got:\n{}", result);
1040 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè"));
1042 assert!(result.contains("hsk: 1"));
1044 assert!(result.contains("ornekler:"));
1045 assert!(result.contains("Body text."));
1046 match change {
1047 ChangeDescription::SetField {
1048 field, new_value, ..
1049 } => {
1050 assert_eq!(field, "kaliplar");
1051 assert_eq!(new_value, "something");
1052 }
1053 _ => panic!("expected SetField"),
1054 }
1055 }
1056
1057 #[test]
1058 fn set_field_initializes_frontmatter_on_bare_file() {
1059 let bare = "# Just a heading\n\nSome body text.\n";
1062 let (result, _) = set_field(bare, "db-table", "rusen-wiki").unwrap();
1063 assert!(result.starts_with("---\n"), "got:\n{}", result);
1064 assert!(result.contains("db-table: rusen-wiki"));
1065 assert!(result.contains("# Just a heading"));
1067 assert!(result.contains("Some body text."));
1068 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
1070 let fm = &result[4..fm_end];
1071 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
1072 assert_eq!(
1073 parsed
1074 .as_mapping()
1075 .and_then(|m| m.get("db-table"))
1076 .and_then(|v| v.as_str()),
1077 Some("rusen-wiki")
1078 );
1079 }
1080
1081 #[test]
1082 fn set_value_needing_quotes() {
1083 let (result, _) = set_field(MOVIE_FILE, "note", "key: value").unwrap();
1084 assert!(result.contains("note: 'key: value'"));
1085 }
1086
1087 #[test]
1090 fn set_field_block_inserts_new_list_as_block_yaml() {
1091 let value = Value::List(vec![Value::String("kedi".into())]);
1092 let (result, change) = set_field_block(MOVIE_FILE, "anlamlar", &value).unwrap();
1093 assert!(result.contains("anlamlar:\n- kedi"));
1096 assert!(!result.contains("anlamlar: '- kedi'"));
1097 let closing_idx = result.rfind("\n---\n").unwrap();
1099 assert!(result.find("anlamlar:").unwrap() < closing_idx);
1100 match change {
1101 ChangeDescription::SetField {
1102 field, new_value, ..
1103 } => {
1104 assert_eq!(field, "anlamlar");
1105 assert_eq!(new_value.trim_end(), "- kedi");
1106 }
1107 _ => panic!("expected SetField"),
1108 }
1109 }
1110
1111 #[test]
1112 fn set_field_block_multi_item_list_round_trips() {
1113 let value = Value::List(vec![
1114 Value::String("猫が好きです。".into()),
1115 Value::String("私の猫は黒いです。".into()),
1116 ]);
1117 let (result, _) = set_field_block(MOVIE_FILE, "ornekler_jp", &value).unwrap();
1118 assert!(result.contains("ornekler_jp:\n- 猫が好きです。\n- 私の猫は黒いです。"));
1119 let fm_end = result[4..].find("\n---\n").unwrap() + 4;
1121 let fm = &result[4..fm_end];
1122 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
1123 let items = parsed
1124 .as_mapping()
1125 .and_then(|m| m.get("ornekler_jp"))
1126 .and_then(|v| v.as_sequence())
1127 .expect("ornekler_jp must round-trip as a YAML sequence");
1128 assert_eq!(items.len(), 2);
1129 }
1130
1131 #[test]
1132 fn set_field_block_replaces_existing_block_list() {
1133 let value = Value::List(vec![Value::String("replaced".into())]);
1136 let (result, _) = set_field_block(CHINESE_FILE, "kaliplar", &value).unwrap();
1137 assert!(result.contains("kaliplar:\n- replaced"));
1138 assert!(!result.contains("快乐")); assert!(!result.contains("kuàilè")); assert!(result.contains("hsk: 1"));
1142 assert!(result.contains("ornekler:"));
1143 }
1144
1145 #[test]
1146 fn set_field_block_writes_map_as_nested_yaml() {
1147 let mut m: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
1148 m.insert("k1".into(), Value::String("v1".into()));
1149 m.insert("k2".into(), Value::Integer(2));
1150 let value = Value::Map(m);
1151 let (result, _) = set_field_block(MOVIE_FILE, "meta", &value).unwrap();
1152 assert!(result.contains("meta:\n k1: v1\n k2: 2"));
1153 }
1154
1155 #[test]
1156 fn set_field_block_rejects_flow_style_existing() {
1157 let content = "---\ntags: [a, b]\n---\nbody\n";
1158 let value = Value::List(vec![Value::String("c".into())]);
1159 let err = set_field_block(content, "tags", &value).unwrap_err();
1160 let msg = format!("{}", err);
1161 assert!(msg.contains("flow-style"), "got: {}", msg);
1162 }
1163
1164 #[test]
1165 fn set_field_block_rejects_scalar_value() {
1166 let err =
1168 set_field_block(MOVIE_FILE, "status", &Value::String("watched".into())).unwrap_err();
1169 let msg = format!("{}", err);
1170 assert!(msg.contains("scalar value"), "got: {}", msg);
1171 }
1172
1173 #[test]
1174 fn unset_scalar_field() {
1175 let (result, _) = unset_field(MOVIE_FILE, "director").unwrap();
1176 assert!(!result.contains("director:"));
1177 assert!(result.contains("status: to-watch"));
1179 assert!(result.contains("year: 2019"));
1180 assert!(result.contains("Part of [[Watchlist]]"));
1181 }
1182
1183 #[test]
1184 fn unset_list_field() {
1185 let (result, _) = unset_field(CHINESE_FILE, "kaliplar").unwrap();
1186 assert!(!result.contains("kaliplar:"));
1187 assert!(!result.contains("快乐"));
1188 assert!(result.contains("pinyin: kuài"));
1190 assert!(result.contains("Body text."));
1191 }
1192
1193 #[test]
1194 fn unset_nonexistent_field() {
1195 let result = unset_field(MOVIE_FILE, "nonexistent");
1196 assert!(result.is_err());
1197 }
1198
1199 #[test]
1200 fn add_tag_2space_indent() {
1201 let (result, _) = add_tag(MOVIE_FILE, "genre/war").unwrap();
1202 assert!(result.contains(" - genre/war"));
1203 assert!(result.contains(" - type/leaf"));
1205 assert!(result.contains(" - genre/drama"));
1206 }
1207
1208 #[test]
1209 fn add_tag_0indent() {
1210 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1211 assert!(result.contains("- topic/hsk1"));
1212 assert!(result.contains("- type/concept"));
1214 assert!(result.contains("- topic/chinese"));
1215 }
1216
1217 #[test]
1218 fn remove_tag_2space_indent() {
1219 let (result, _) = remove_tag(MOVIE_FILE, "genre/drama").unwrap();
1220 assert!(!result.contains("genre/drama"));
1221 assert!(result.contains(" - type/leaf"));
1223 assert!(result.contains(" - source/video"));
1224 }
1225
1226 #[test]
1227 fn remove_tag_0indent() {
1228 let (result, _) = remove_tag(CHINESE_FILE, "topic/chinese").unwrap();
1229 assert!(!result.contains("topic/chinese"));
1230 assert!(result.contains("- type/concept"));
1231 assert!(result.contains("- source/self-study"));
1232 }
1233
1234 #[test]
1235 fn remove_nonexistent_tag() {
1236 let result = remove_tag(MOVIE_FILE, "nonexistent/tag");
1237 assert!(result.is_err());
1238 }
1239
1240 #[test]
1241 fn body_preserved_after_set() {
1242 let (result, _) = set_field(MOVIE_FILE, "status", "watched").unwrap();
1243 assert!(result.ends_with("Part of [[Watchlist]]\n"));
1244 }
1245
1246 #[test]
1247 fn body_preserved_after_unset() {
1248 let (result, _) = unset_field(CHINESE_FILE, "hsk").unwrap();
1249 assert!(result.contains("# 快 (kuài) — hızlı"));
1250 assert!(result.contains("Body text."));
1251 }
1252
1253 #[test]
1254 fn body_preserved_after_add_tag() {
1255 let (result, _) = add_tag(CHINESE_FILE, "topic/hsk1").unwrap();
1256 assert!(result.contains("# 快 (kuài) — hızlı"));
1257 }
1258
1259 #[test]
1260 fn chinese_content_preserved() {
1261 let (result, _) = set_field(CHINESE_FILE, "hsk", "2").unwrap();
1262 assert!(result.contains("pinyin: kuài"));
1263 assert!(result.contains("anlam: hızlı"));
1264 assert!(result.contains("tür: sifat"));
1265 assert!(result.contains("kalip: 快乐"));
1266 assert!(result.contains("cumle: 他跑得很快。"));
1267 }
1268
1269 #[test]
1272 fn set_field_rejects_flow_style() {
1273 let content = "---\ntags: [a, b, c]\n---\nBody.\n";
1274 let result = set_field(content, "tags", "x");
1275 assert!(result.is_err());
1276 let err = result.unwrap_err().to_string();
1277 assert!(err.contains("flow-style"));
1278 }
1279
1280 #[test]
1281 fn set_field_rejects_multiline_scalar() {
1282 let content = "---\ndescription: |\n Multi line\n content here\n---\nBody.\n";
1283 let result = set_field(content, "description", "new value");
1284 assert!(result.is_err());
1285 let err = result.unwrap_err().to_string();
1286 assert!(err.contains("multiline"));
1287 }
1288
1289 #[test]
1290 fn add_tag_rejects_flow_style() {
1291 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1292 let result = add_tag(content, "topic/new");
1293 assert!(result.is_err());
1294 let err = result.unwrap_err().to_string();
1295 assert!(err.contains("flow-style"));
1296 }
1297
1298 #[test]
1299 fn remove_tag_rejects_flow_style() {
1300 let content = "---\ntags: [type/concept, topic/ai]\n---\nBody.\n";
1301 let result = remove_tag(content, "topic/ai");
1302 assert!(result.is_err());
1303 let err = result.unwrap_err().to_string();
1304 assert!(err.contains("flow-style"));
1305 }
1306
1307 #[test]
1308 fn atomic_create_refuses_to_overwrite_existing_file() {
1309 use std::fs;
1314 let dir = tempfile::TempDir::new().unwrap();
1315 let target = dir.path().join("note.md");
1316 fs::write(&target, "existing content\n").unwrap();
1317
1318 let err = atomic_create_with(&target, "would clobber\n", WriteOptions::default())
1319 .expect_err("atomic_create must refuse to overwrite");
1320 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1321
1322 assert_eq!(fs::read_to_string(&target).unwrap(), "existing content\n");
1324 }
1325
1326 #[test]
1327 fn atomic_create_writes_to_new_path() {
1328 use std::fs;
1329 let dir = tempfile::TempDir::new().unwrap();
1330 let target = dir.path().join("fresh.md");
1331 atomic_create_with(&target, "hello\n", WriteOptions::default()).unwrap();
1332 assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
1333 }
1334
1335 #[test]
1336 fn set_field_preformatted_writes_value_verbatim() {
1337 let content = "---\nurl:\n---\nBody\n";
1342 let preformatted = "'https://www.amazon.com.tr/foo'";
1343 let (out, _) = set_field_preformatted(content, "url", preformatted).unwrap();
1344 assert!(
1345 out.contains("url: 'https://www.amazon.com.tr/foo'"),
1346 "got:\n{}",
1347 out
1348 );
1349 assert!(
1350 !out.contains("url: \"'"),
1351 "preformatted value was double-quoted; got:\n{}",
1352 out
1353 );
1354 }
1355
1356 #[test]
1359 fn set_body_replaces_existing_body() {
1360 let (result, change) = set_body(MOVIE_FILE, "New body content.\n").unwrap();
1361 assert!(result.contains("director: Sam Mendes"));
1363 assert!(result.contains("status: to-watch"));
1364 assert!(!result.contains("Part of [[Watchlist]]"));
1366 assert!(result.contains("New body content."));
1367 assert!(result.ends_with("---\nNew body content.\n"));
1369 match change {
1370 ChangeDescription::SetBody { new_len, .. } => assert_eq!(new_len, 18),
1371 other => panic!("expected SetBody, got {:?}", other),
1372 }
1373 }
1374
1375 #[test]
1376 fn set_body_writes_verbatim_no_trailing_newline_added() {
1377 let (result, _) = set_body(MOVIE_FILE, "no newline").unwrap();
1380 assert!(result.ends_with("---\nno newline"));
1381 }
1382
1383 #[test]
1384 fn set_body_on_frontmatter_only_file() {
1385 let fm_only = "---\nstatus: x\n---\n";
1387 let (result, _) = set_body(fm_only, "Hello.\n").unwrap();
1388 assert_eq!(result, "---\nstatus: x\n---\nHello.\n");
1389 }
1390
1391 #[test]
1392 fn set_body_on_bare_file_synthesizes_frontmatter() {
1393 let bare = "Just a bare note.\n";
1397 let (result, change) = set_body(bare, "Replaced.\n").unwrap();
1398 assert!(result.starts_with("---\n---\n"));
1399 assert!(result.ends_with("Replaced.\n"));
1400 assert!(!result.contains("Just a bare note"));
1401 match change {
1402 ChangeDescription::SetBody { old_len, new_len } => {
1403 assert_eq!(old_len, bare.len());
1404 assert_eq!(new_len, "Replaced.\n".len());
1405 }
1406 other => panic!("expected SetBody, got {:?}", other),
1407 }
1408 }
1409
1410 #[test]
1411 fn clear_body_keeps_frontmatter_and_drops_body() {
1412 let (result, change) = clear_body(MOVIE_FILE).unwrap();
1413 assert!(result.contains("director: Sam Mendes"));
1414 assert!(!result.contains("Part of [[Watchlist]]"));
1415 assert!(result.ends_with("---\n"));
1417 match change {
1418 ChangeDescription::ClearBody { old_len } => assert!(old_len > 0),
1419 other => panic!("expected ClearBody, got {:?}", other),
1420 }
1421 }
1422
1423 #[test]
1424 fn append_body_on_existing_body_uses_separator() {
1425 let (result, change) = append_body(MOVIE_FILE, "Next line.", "\n").unwrap();
1430 assert!(result.contains("Part of [[Watchlist]]"));
1431 assert!(result.ends_with("Part of [[Watchlist]]\nNext line."));
1432 match change {
1433 ChangeDescription::AppendBody { added_len } => assert_eq!(added_len, 10),
1434 other => panic!("expected AppendBody, got {:?}", other),
1435 }
1436 }
1437
1438 #[test]
1439 fn append_body_with_custom_separator() {
1440 let fm_with_body = "---\nstatus: x\n---\nFirst.\n";
1443 let (result, _) = append_body(fm_with_body, "Second.", "\n\n").unwrap();
1444 assert!(result.ends_with("First.\n\nSecond."));
1445 }
1446
1447 #[test]
1448 fn append_body_on_empty_body_skips_separator() {
1449 let fm_only = "---\nstatus: x\n---\n";
1451 let (result, _) = append_body(fm_only, "First line.", "\n").unwrap();
1452 assert_eq!(result, "---\nstatus: x\n---\nFirst line.");
1453 }
1454
1455 #[test]
1456 fn append_body_idempotent_against_trailing_newlines() {
1457 let start = "---\n---\n";
1460 let (r1, _) = append_body(start, "a\n", "\n").unwrap();
1461 let (r2, _) = append_body(&r1, "b\n", "\n").unwrap();
1462 let (r3, _) = append_body(&r2, "c\n", "\n").unwrap();
1463 assert_eq!(r3, "---\n---\na\nb\nc\n");
1464 }
1465
1466 #[test]
1467 fn append_body_on_bare_file_appends_after_original() {
1468 let bare = "Existing.\n";
1471 let (result, _) = append_body(bare, "More.", "\n").unwrap();
1472 assert!(result.starts_with("---\n---\n"));
1473 assert!(result.ends_with("Existing.\nMore."));
1474 }
1475
1476 #[test]
1477 fn set_field_still_quotes_raw_values() {
1478 let content = "---\nurl:\n---\n";
1481 let (out, _) = set_field(content, "url", "https://www.example.com").unwrap();
1482 assert!(
1483 out.contains("url: 'https://www.example.com'"),
1484 "got:\n{}",
1485 out
1486 );
1487 assert!(!out.contains("url: \"'"), "got:\n{}", out);
1489 }
1490}