1use std::collections::HashMap;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6pub(crate) trait Transform {
11 type Input;
12 type Output;
13 fn transform(&self, input: Self::Input) -> Result<Option<Self::Output>, String>;
14}
15
16pub(crate) trait Dedup {
25 fn check(&self, hash: &str) -> Option<&str>;
26 fn commit(&mut self, hash: &str, relative_path: &str) -> Result<(), String>;
27}
28
29#[derive(Clone)]
39struct ContentIndexEntry {
40 relative_path: String,
41 original_key: String,
42 source_bucket_alias: String,
43}
44
45pub(crate) struct ContentIndex {
55 staging_dir: PathBuf,
56 file_name: &'static str,
57 entries: HashMap<String, ContentIndexEntry>,
58}
59
60impl ContentIndex {
61 pub(crate) fn load(
71 staging_dir: &Path,
72 file_name: &'static str,
73 ) -> Result<ContentIndex, String> {
74 let path = staging_dir.join(file_name);
75 let contents = match fs::read_to_string(&path) {
76 Ok(contents) => contents,
77 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
78 Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
79 };
80 let entries = contents
81 .lines()
82 .filter_map(|line| {
83 if let Some((hash, rest)) = line.split_once('\t') {
84 let (relative_path, rest) = rest.split_once('\t').unwrap_or((rest, ""));
85 let (original_key, source_bucket_alias) =
86 rest.split_once('\t').unwrap_or((rest, ""));
87 Some((
88 hash.to_string(),
89 ContentIndexEntry {
90 relative_path: relative_path.to_string(),
91 original_key: original_key.to_string(),
92 source_bucket_alias: source_bucket_alias.to_string(),
93 },
94 ))
95 } else {
96 line.split_once(' ').map(|(hash, relative_path)| {
97 (
98 hash.to_string(),
99 ContentIndexEntry {
100 relative_path: relative_path.to_string(),
101 original_key: String::new(),
102 source_bucket_alias: String::new(),
103 },
104 )
105 })
106 }
107 })
108 .collect();
109 Ok(ContentIndex {
110 staging_dir: staging_dir.to_path_buf(),
111 file_name,
112 entries,
113 })
114 }
115
116 pub(crate) fn check(&self, hash: &str) -> Option<&str> {
119 self.entries
120 .get(hash)
121 .map(|entry| entry.relative_path.as_str())
122 }
123
124 pub(crate) fn check_with_original_key_and_bucket(
132 &self,
133 hash: &str,
134 ) -> Option<(&str, &str, &str)> {
135 self.entries.get(hash).map(|entry| {
136 (
137 entry.relative_path.as_str(),
138 entry.original_key.as_str(),
139 entry.source_bucket_alias.as_str(),
140 )
141 })
142 }
143
144 pub(crate) fn commit(&mut self, hash: &str, relative_path: &str) -> Result<(), String> {
150 self.commit_with_key(hash, relative_path, "")
151 }
152
153 pub(crate) fn commit_with_key(
161 &mut self,
162 hash: &str,
163 relative_path: &str,
164 original_key: &str,
165 ) -> Result<(), String> {
166 let path = self.staging_dir.join(self.file_name);
167 let mut file = fs::OpenOptions::new()
168 .create(true)
169 .append(true)
170 .open(&path)
171 .map_err(|err| format!("failed to open {}: {err}", path.display()))?;
172 writeln!(file, "{hash}\t{relative_path}\t{original_key}")
173 .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
174 self.entries.insert(
175 hash.to_string(),
176 ContentIndexEntry {
177 relative_path: relative_path.to_string(),
178 original_key: original_key.to_string(),
179 source_bucket_alias: String::new(),
180 },
181 );
182 Ok(())
183 }
184
185 pub(crate) fn commit_with_key_and_bucket(
194 &mut self,
195 hash: &str,
196 relative_path: &str,
197 original_key: &str,
198 source_bucket_alias: &str,
199 ) -> Result<(), String> {
200 let path = self.staging_dir.join(self.file_name);
201 let mut file = fs::OpenOptions::new()
202 .create(true)
203 .append(true)
204 .open(&path)
205 .map_err(|err| format!("failed to open {}: {err}", path.display()))?;
206 writeln!(
207 file,
208 "{hash}\t{relative_path}\t{original_key}\t{source_bucket_alias}"
209 )
210 .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
211 self.entries.insert(
212 hash.to_string(),
213 ContentIndexEntry {
214 relative_path: relative_path.to_string(),
215 original_key: original_key.to_string(),
216 source_bucket_alias: source_bucket_alias.to_string(),
217 },
218 );
219 Ok(())
220 }
221}
222
223pub(crate) fn amend_frontmatter_for_duplicate(
238 canonical_path: &Path,
239 tag: &str,
240 occurrence: u32,
241) -> Result<bool, String> {
242 let contents = fs::read_to_string(canonical_path)
243 .map_err(|err| format!("failed to read {}: {err}", canonical_path.display()))?;
244 let had_trailing_newline = contents.ends_with('\n');
245 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
246
247 if lines.first().map(String::as_str) != Some("---") {
248 return Err(format!(
249 "{} does not start with a frontmatter delimiter",
250 canonical_path.display()
251 ));
252 }
253 let Some(mut close_idx) = lines
254 .iter()
255 .skip(1)
256 .position(|line| line == "---")
257 .map(|i| i + 1)
258 else {
259 return Err(format!(
260 "{} has no closing frontmatter delimiter",
261 canonical_path.display()
262 ));
263 };
264
265 let Some(tags_idx) = lines[1..close_idx]
266 .iter()
267 .position(|line| line == "tags:")
268 .map(|i| i + 1)
269 else {
270 return Err(format!("{} has no tags: field", canonical_path.display()));
271 };
272 let mut tags_block_end = lines[tags_idx + 1..close_idx]
273 .iter()
274 .take_while(|line| line.starts_with(" - "))
275 .count()
276 + tags_idx
277 + 1;
278
279 let mut changed = false;
280 let tag_line = format!(" - {tag}");
281 let has_tag = lines[tags_idx + 1..tags_block_end]
282 .iter()
283 .any(|line| line == &tag_line);
284 if !has_tag {
285 lines.insert(tags_block_end, tag_line);
286 tags_block_end += 1;
287 close_idx += 1;
288 changed = true;
289 }
290
291 let also_in_header = "also-in:";
292 let existing_also_in_idx = lines[tags_block_end..close_idx]
293 .iter()
294 .position(|line| line == also_in_header)
295 .map(|i| i + tags_block_end);
296
297 let new_entry_prefix = format!(" - {tag}#");
298 let new_entry = format!(" - {tag}#{occurrence}");
299
300 match existing_also_in_idx {
301 Some(also_in_idx) => {
302 let also_in_block_end = lines[also_in_idx + 1..close_idx]
303 .iter()
304 .take_while(|line| line.starts_with(" - "))
305 .count()
306 + also_in_idx
307 + 1;
308 let existing_entry_idx = lines[also_in_idx + 1..also_in_block_end]
309 .iter()
310 .position(|line| line.starts_with(&new_entry_prefix))
311 .map(|i| i + also_in_idx + 1);
312 match existing_entry_idx {
313 Some(idx) => {
314 if lines[idx] != new_entry {
315 lines[idx] = new_entry;
316 changed = true;
317 }
318 }
319 None => {
320 lines.insert(also_in_block_end, new_entry);
321 changed = true;
322 }
323 }
324 }
325 None => {
326 lines.insert(tags_block_end, also_in_header.to_string());
327 lines.insert(tags_block_end + 1, new_entry);
328 changed = true;
329 }
330 }
331
332 if !changed {
333 return Ok(false);
334 }
335
336 let mut rewritten = lines.join("\n");
337 if had_trailing_newline {
338 rewritten.push('\n');
339 }
340 fs::write(canonical_path, rewritten)
341 .map_err(|err| format!("failed to write {}: {err}", canonical_path.display()))?;
342 Ok(true)
343}
344
345pub(crate) fn rewrite_attachment_reference(
359 md_path: &Path,
360 old_relpath: &str,
361 new_relpath: &str,
362) -> Result<bool, String> {
363 let contents = fs::read_to_string(md_path)
364 .map_err(|err| format!("failed to read {}: {err}", md_path.display()))?;
365 let had_trailing_newline = contents.ends_with('\n');
366 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
367
368 let old_line = format!(" - {old_relpath}");
369 let Some(idx) = lines.iter().position(|line| line == &old_line) else {
370 return Ok(false);
371 };
372 lines[idx] = format!(" - {new_relpath}");
373
374 let mut rewritten = lines.join("\n");
375 if had_trailing_newline {
376 rewritten.push('\n');
377 }
378 fs::write(md_path, rewritten)
379 .map_err(|err| format!("failed to write {}: {err}", md_path.display()))?;
380 Ok(true)
381}
382
383pub(crate) fn yaml_quote(s: &str) -> String {
391 let mut escaped = String::with_capacity(s.len());
392 for c in s.chars() {
393 match c {
394 '\\' => escaped.push_str("\\\\"),
395 '"' => escaped.push_str("\\\""),
396 '\n' => escaped.push_str("\\n"),
397 '\r' => escaped.push_str("\\r"),
398 '\t' => escaped.push_str("\\t"),
399 c if (c as u32) < 0x20 || c as u32 == 0x7f => {
400 escaped.push_str(&format!("\\x{:02x}", c as u32));
401 }
402 c => escaped.push(c),
403 }
404 }
405 format!("\"{escaped}\"")
406}
407
408pub(crate) fn unique_path(desired: &Path) -> PathBuf {
411 if !desired.exists() {
412 return desired.to_path_buf();
413 }
414 let stem = desired
415 .file_stem()
416 .and_then(|stem| stem.to_str())
417 .unwrap_or("file");
418 let ext = desired.extension().and_then(|ext| ext.to_str());
419 let parent = desired.parent().unwrap_or_else(|| Path::new(""));
420
421 let mut n = 2;
422 loop {
423 let candidate_name = match ext {
424 Some(ext) => format!("{stem}-{n}.{ext}"),
425 None => format!("{stem}-{n}"),
426 };
427 let candidate = parent.join(candidate_name);
428 if !candidate.exists() {
429 return candidate;
430 }
431 n += 1;
432 }
433}
434
435const MAX_FILENAME_LENGTH: usize = 100;
439
440pub(crate) fn sanitize_filename(name: &str) -> String {
449 let base = match Path::new(name).file_name().and_then(|f| f.to_str()) {
450 Some(base) if !base.is_empty() => base,
451 _ => return "file".to_string(),
452 };
453 truncate_preserving_extension(base, MAX_FILENAME_LENGTH)
454}
455
456fn truncate_preserving_extension(name: &str, max_len: usize) -> String {
462 if name.len() <= max_len {
463 return name.to_string();
464 }
465 if let Some((stem, ext)) = name.rsplit_once('.')
466 && !ext.is_empty()
467 && ext.len() + 1 < max_len
468 {
469 return format!(
470 "{}.{ext}",
471 truncate_at_char_boundary(stem, max_len - ext.len() - 1)
472 );
473 }
474 truncate_at_char_boundary(name, max_len)
475}
476
477fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> String {
478 let mut end = max_bytes.min(s.len());
479 while end > 0 && !s.is_char_boundary(end) {
480 end -= 1;
481 }
482 s[..end].to_string()
483}
484
485pub(crate) fn collect_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
491 let mut files = Vec::new();
492 if dir.exists() {
493 visit_dir(dir, &mut files)?;
494 }
495 files.sort();
496 Ok(files)
497}
498
499fn visit_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
500 let entries =
501 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
502 for entry in entries {
503 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
504 let path = entry.path();
505 if path.is_dir() {
506 visit_dir(&path, files)?;
507 } else {
508 files.push(path);
509 }
510 }
511 Ok(())
512}
513
514pub(crate) fn is_apple_double_basename(key: &str) -> bool {
526 key.rsplit('/').next().unwrap_or(key).starts_with("._")
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn is_apple_double_basename_matches_only_a_resource_fork_sidecar() {
535 assert!(is_apple_double_basename("._IMG_0001.heic"));
536 assert!(is_apple_double_basename("photos/nested/._IMG_0001.heic"));
537 assert!(!is_apple_double_basename("IMG_0001.heic"));
538 assert!(!is_apple_double_basename("photos/IMG_0001.heic"));
539 assert!(!is_apple_double_basename(".hidden.heic"));
541 assert!(!is_apple_double_basename("._dir/IMG_0001.heic"));
543 }
544
545 #[test]
546 fn content_index_commit_with_key_round_trips_the_original_key() {
547 let dir = tempfile::tempdir().unwrap();
548 let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
549
550 index
551 .commit_with_key("hash-a", "pdf/report.pdf", "docs/report.pdf")
552 .unwrap();
553
554 assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
555 assert_eq!(
556 index.check_with_original_key_and_bucket("hash-a"),
557 Some(("pdf/report.pdf", "docs/report.pdf", ""))
558 );
559
560 let reloaded = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
562 assert_eq!(
563 reloaded.check_with_original_key_and_bucket("hash-a"),
564 Some(("pdf/report.pdf", "docs/report.pdf", ""))
565 );
566 }
567
568 #[test]
569 fn content_index_commit_with_key_and_bucket_round_trips_the_source_bucket_alias() {
570 let dir = tempfile::tempdir().unwrap();
571 let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
572
573 index
574 .commit_with_key_and_bucket("hash-a", "pdf/report.pdf", "docs/report.pdf", "bucket-a")
575 .unwrap();
576
577 assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
578 assert_eq!(
579 index.check_with_original_key_and_bucket("hash-a"),
580 Some(("pdf/report.pdf", "docs/report.pdf", "bucket-a"))
581 );
582
583 let reloaded = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
585 assert_eq!(
586 reloaded.check_with_original_key_and_bucket("hash-a"),
587 Some(("pdf/report.pdf", "docs/report.pdf", "bucket-a"))
588 );
589 }
590
591 #[test]
592 fn content_index_commit_writes_an_empty_original_key() {
593 let dir = tempfile::tempdir().unwrap();
594 let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
595
596 index.commit("hash-a", "pdf/report.pdf").unwrap();
597
598 assert_eq!(
599 index.check_with_original_key_and_bucket("hash-a"),
600 Some(("pdf/report.pdf", "", ""))
601 );
602 }
603
604 #[test]
605 fn content_index_load_is_backward_compatible_with_the_old_space_separated_format() {
606 let dir = tempfile::tempdir().unwrap();
607 std::fs::write(
608 dir.path().join(".content-hashes"),
609 "hash-a pdf/report.pdf\n",
610 )
611 .unwrap();
612
613 let index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
614
615 assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
616 assert_eq!(
617 index.check_with_original_key_and_bucket("hash-a"),
618 Some(("pdf/report.pdf", "", ""))
619 );
620 }
621
622 #[test]
623 fn content_index_load_is_backward_compatible_with_the_old_3_field_format() {
624 let dir = tempfile::tempdir().unwrap();
625 std::fs::write(
626 dir.path().join(".content-hashes"),
627 "hash-a\tpdf/report.pdf\tdocs/report.pdf\n",
628 )
629 .unwrap();
630
631 let index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
632
633 assert_eq!(
634 index.check_with_original_key_and_bucket("hash-a"),
635 Some(("pdf/report.pdf", "docs/report.pdf", ""))
636 );
637 }
638
639 const MESSAGE_HASHES: &str = ".message-hashes";
640 const ATTACHMENT_HASHES: &str = ".attachment-hashes";
641
642 const FIXTURE: &str = "---\n\
643 from: \"Jane Doe <jane.doe@example.com>\"\n\
644 to: \"first.last@example.com\"\n\
645 subject: \"Hello, World!\"\n\
646 date: 2024-01-26T09:15:00+00:00\n\
647 tags:\n\
648 \x20\x20- mailbox/inbox\n\
649 \x20\x20- identity/first-last\n\
650 attachments:\n\
651 \x20\x20- attachments/2024-01-26-hello-world-bingo.pdf\n\
652 uid: 482\n\
653 ---\n\
654 \n\
655 Hello there!\n";
656
657 #[test]
658 fn load_missing_file_is_empty() {
659 let dir = tempfile::tempdir().unwrap();
660 let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
661 assert!(index.check("abc").is_none());
662 }
663
664 #[test]
665 fn commit_then_check_round_trips() {
666 let dir = tempfile::tempdir().unwrap();
667 let mut index = ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap();
668 index.commit("hash1", "identity/attachments/a.pdf").unwrap();
669 assert_eq!(index.check("hash1"), Some("identity/attachments/a.pdf"));
670 assert!(index.check("hash2").is_none());
671 }
672
673 #[test]
674 fn load_picks_up_entries_committed_by_a_prior_load() {
675 let dir = tempfile::tempdir().unwrap();
676 let mut first = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
677 first.commit("hash1", "identity/a.md").unwrap();
678
679 let second = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
680 assert_eq!(second.check("hash1"), Some("identity/a.md"));
681 }
682
683 #[test]
684 fn load_skips_malformed_lines() {
685 let dir = tempfile::tempdir().unwrap();
686 fs::write(dir.path().join(MESSAGE_HASHES), "no-space-here\n").unwrap();
687 let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
688 assert!(index.check("no-space-here").is_none());
689 }
690
691 #[test]
692 fn amend_adds_new_tag_and_also_in_entry() {
693 let dir = tempfile::tempdir().unwrap();
694 let path = dir.path().join("canonical.md");
695 fs::write(&path, FIXTURE).unwrap();
696
697 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
698 assert!(changed);
699
700 let contents = fs::read_to_string(&path).unwrap();
701 assert!(contents.contains(" - mailbox/archive\n"));
702 assert!(contents.contains("also-in:\n - mailbox/archive#45\n"));
703 assert!(contents.contains("from: \"Jane Doe <jane.doe@example.com>\""));
704 assert!(
705 contents.contains("attachments:\n - attachments/2024-01-26-hello-world-bingo.pdf")
706 );
707 assert!(contents.contains("uid: 482"));
708 assert!(contents.ends_with("Hello there!\n"));
709 }
710
711 #[test]
712 fn amend_is_idempotent_for_identical_tag_and_occurrence() {
713 let dir = tempfile::tempdir().unwrap();
714 let path = dir.path().join("canonical.md");
715 fs::write(&path, FIXTURE).unwrap();
716
717 amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
718 let after_first = fs::read_to_string(&path).unwrap();
719
720 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
721 assert!(!changed);
722 assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
723 }
724
725 #[test]
726 fn amend_updates_occurrence_in_place() {
727 let dir = tempfile::tempdir().unwrap();
728 let path = dir.path().join("canonical.md");
729 fs::write(&path, FIXTURE).unwrap();
730
731 amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
732 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 99).unwrap();
733 assert!(changed);
734
735 let contents = fs::read_to_string(&path).unwrap();
736 assert!(contents.contains(" - mailbox/archive#99"));
737 assert!(!contents.contains(" - mailbox/archive#45"));
738 assert_eq!(contents.matches("mailbox/archive#").count(), 1);
739 }
740
741 #[test]
742 fn amend_errors_on_missing_canonical_file() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = dir.path().join("does-not-exist.md");
745 assert!(amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).is_err());
746 }
747
748 #[test]
749 fn rewrite_attachment_reference_replaces_matching_line() {
750 let dir = tempfile::tempdir().unwrap();
751 let path = dir.path().join("canonical.md");
752 fs::write(&path, FIXTURE).unwrap();
753
754 let changed = rewrite_attachment_reference(
755 &path,
756 "attachments/2024-01-26-hello-world-bingo.pdf",
757 "attachments/2024-01-26-hello-world-bingo-2.pdf",
758 )
759 .unwrap();
760 assert!(changed);
761
762 let contents = fs::read_to_string(&path).unwrap();
763 assert!(
764 contents.contains("attachments:\n - attachments/2024-01-26-hello-world-bingo-2.pdf")
765 );
766 assert!(!contents.contains("attachments/2024-01-26-hello-world-bingo.pdf\n"));
767 }
768
769 #[test]
770 fn rewrite_attachment_reference_is_idempotent_when_old_path_already_gone() {
771 let dir = tempfile::tempdir().unwrap();
772 let path = dir.path().join("canonical.md");
773 fs::write(&path, FIXTURE).unwrap();
774
775 rewrite_attachment_reference(
776 &path,
777 "attachments/2024-01-26-hello-world-bingo.pdf",
778 "attachments/canonical.pdf",
779 )
780 .unwrap();
781 let after_first = fs::read_to_string(&path).unwrap();
782
783 let changed = rewrite_attachment_reference(
784 &path,
785 "attachments/2024-01-26-hello-world-bingo.pdf",
786 "attachments/canonical.pdf",
787 )
788 .unwrap();
789 assert!(!changed);
790 assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
791 }
792
793 #[test]
794 fn rewrite_attachment_reference_errors_on_missing_file() {
795 let dir = tempfile::tempdir().unwrap();
796 let path = dir.path().join("does-not-exist.md");
797 assert!(
798 rewrite_attachment_reference(&path, "attachments/a.pdf", "attachments/b.pdf").is_err()
799 );
800 }
801
802 #[test]
803 fn commit_survives_concurrent_access_from_multiple_threads() {
804 use std::sync::{Arc, Mutex};
805 use std::thread;
806
807 let dir = tempfile::tempdir().unwrap();
808 let index = Arc::new(Mutex::new(
809 ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap(),
810 ));
811
812 let handles: Vec<_> = (0..8)
813 .map(|n| {
814 let index = Arc::clone(&index);
815 thread::spawn(move || {
816 index
817 .lock()
818 .unwrap()
819 .commit(&format!("hash{n}"), &format!("identity/a{n}.pdf"))
820 .unwrap();
821 })
822 })
823 .collect();
824 for handle in handles {
825 handle.join().unwrap();
826 }
827
828 let guard = index.lock().unwrap();
829 for n in 0..8 {
830 assert_eq!(
831 guard.check(&format!("hash{n}")),
832 Some(format!("identity/a{n}.pdf")).as_deref()
833 );
834 }
835
836 let contents = fs::read_to_string(dir.path().join(ATTACHMENT_HASHES)).unwrap();
837 assert_eq!(contents.lines().count(), 8);
838 }
839
840 #[test]
841 fn yaml_quote_escapes_quotes_and_backslashes() {
842 assert_eq!(yaml_quote("Hello: World"), "\"Hello: World\"");
843 assert_eq!(yaml_quote(r#"She said "hi""#), r#""She said \"hi\"""#);
844 }
845
846 #[test]
847 fn yaml_quote_escapes_embedded_newline_and_carriage_return() {
848 assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
849 assert_eq!(yaml_quote("a\r\nb"), "\"a\\r\\nb\"");
850 }
851
852 #[test]
853 fn yaml_quote_escapes_other_control_characters() {
854 assert_eq!(yaml_quote("a\tb"), "\"a\\tb\"");
855 assert_eq!(yaml_quote("a\x01b"), "\"a\\x01b\"");
856 }
857
858 #[test]
859 fn yaml_quote_leaves_non_ascii_names_unchanged() {
860 assert_eq!(yaml_quote("José García"), "\"José García\"");
861 }
862
863 #[test]
864 fn yaml_quote_prevents_frontmatter_line_injection() {
865 let malicious = "Evil\ntags:\n - admin";
870 assert!(!yaml_quote(malicious).contains('\n'));
871 }
872
873 #[test]
874 fn unique_path_returns_original_when_free() {
875 let dir = tempfile::tempdir().unwrap();
876 let desired = dir.path().join("2024-01-26-hello.md");
877 assert_eq!(unique_path(&desired), desired);
878 }
879
880 #[test]
881 fn unique_path_suffixes_on_collision() {
882 let dir = tempfile::tempdir().unwrap();
883 let desired = dir.path().join("2024-01-26-hello.md");
884 fs::write(&desired, b"").unwrap();
885
886 let resolved = unique_path(&desired);
887 assert_eq!(resolved, dir.path().join("2024-01-26-hello-2.md"));
888 }
889
890 #[test]
891 fn sanitize_filename_strips_leading_slash() {
892 assert_eq!(sanitize_filename("/img0.png"), "img0.png");
893 }
894
895 #[test]
896 fn sanitize_filename_strips_nested_directories() {
897 assert_eq!(sanitize_filename("a/b/c.pdf"), "c.pdf");
898 }
899
900 #[test]
901 fn sanitize_filename_preserves_normal_name() {
902 assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
903 }
904
905 #[test]
906 fn sanitize_filename_falls_back_for_dot_dot() {
907 assert_eq!(sanitize_filename(".."), "file");
908 }
909
910 #[test]
911 fn sanitize_filename_falls_back_for_bare_slash() {
912 assert_eq!(sanitize_filename("/"), "file");
913 }
914
915 #[test]
916 fn sanitize_filename_truncates_long_name_preserving_extension() {
917 let long_name = format!("{}.pdf", "a".repeat(300));
918 let sanitized = sanitize_filename(&long_name);
919 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
920 assert!(sanitized.ends_with(".pdf"));
921 }
922
923 #[test]
924 fn sanitize_filename_truncates_long_name_with_no_extension() {
925 let long_name = "a".repeat(300);
926 let sanitized = sanitize_filename(&long_name);
927 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
928 }
929
930 #[test]
931 fn sanitize_filename_truncates_multibyte_name_at_char_boundary() {
932 let long_name = format!("{}.png", "é".repeat(200));
935 let sanitized = sanitize_filename(&long_name);
936 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
937 assert!(sanitized.ends_with(".png"));
938 assert!(sanitized.is_char_boundary(sanitized.len()));
939 }
940
941 #[test]
942 fn collect_files_walks_nested_directories() {
943 let dir = tempfile::tempdir().unwrap();
944 fs::create_dir_all(dir.path().join("attachments")).unwrap();
945 fs::write(dir.path().join("hello.md"), b"hi").unwrap();
946 fs::write(dir.path().join("attachments/a.pdf"), b"pdf").unwrap();
947
948 let mut files = collect_files(dir.path()).unwrap();
949 files.sort();
950
951 let mut expected = vec![
952 dir.path().join("attachments/a.pdf"),
953 dir.path().join("hello.md"),
954 ];
955 expected.sort();
956
957 assert_eq!(files, expected);
958 }
959
960 #[test]
961 fn collect_files_missing_directory_is_empty() {
962 let dir = tempfile::tempdir().unwrap();
963 let missing = dir.path().join("does-not-exist");
964 assert!(collect_files(&missing).unwrap().is_empty());
965 }
966}