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)]
35struct ContentIndexEntry {
36 relative_path: String,
37 original_key: String,
38}
39
40pub(crate) struct ContentIndex {
49 staging_dir: PathBuf,
50 file_name: &'static str,
51 entries: HashMap<String, ContentIndexEntry>,
52}
53
54impl ContentIndex {
55 pub(crate) fn load(
63 staging_dir: &Path,
64 file_name: &'static str,
65 ) -> Result<ContentIndex, String> {
66 let path = staging_dir.join(file_name);
67 let contents = match fs::read_to_string(&path) {
68 Ok(contents) => contents,
69 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
70 Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
71 };
72 let entries = contents
73 .lines()
74 .filter_map(|line| {
75 if let Some((hash, rest)) = line.split_once('\t') {
76 let (relative_path, original_key) = rest.split_once('\t').unwrap_or((rest, ""));
77 Some((
78 hash.to_string(),
79 ContentIndexEntry {
80 relative_path: relative_path.to_string(),
81 original_key: original_key.to_string(),
82 },
83 ))
84 } else {
85 line.split_once(' ').map(|(hash, relative_path)| {
86 (
87 hash.to_string(),
88 ContentIndexEntry {
89 relative_path: relative_path.to_string(),
90 original_key: String::new(),
91 },
92 )
93 })
94 }
95 })
96 .collect();
97 Ok(ContentIndex {
98 staging_dir: staging_dir.to_path_buf(),
99 file_name,
100 entries,
101 })
102 }
103
104 pub(crate) fn check(&self, hash: &str) -> Option<&str> {
107 self.entries
108 .get(hash)
109 .map(|entry| entry.relative_path.as_str())
110 }
111
112 pub(crate) fn check_with_original_key(&self, hash: &str) -> Option<(&str, &str)> {
116 self.entries
117 .get(hash)
118 .map(|entry| (entry.relative_path.as_str(), entry.original_key.as_str()))
119 }
120
121 pub(crate) fn commit(&mut self, hash: &str, relative_path: &str) -> Result<(), String> {
127 self.commit_with_key(hash, relative_path, "")
128 }
129
130 pub(crate) fn commit_with_key(
135 &mut self,
136 hash: &str,
137 relative_path: &str,
138 original_key: &str,
139 ) -> Result<(), String> {
140 let path = self.staging_dir.join(self.file_name);
141 let mut file = fs::OpenOptions::new()
142 .create(true)
143 .append(true)
144 .open(&path)
145 .map_err(|err| format!("failed to open {}: {err}", path.display()))?;
146 writeln!(file, "{hash}\t{relative_path}\t{original_key}")
147 .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
148 self.entries.insert(
149 hash.to_string(),
150 ContentIndexEntry {
151 relative_path: relative_path.to_string(),
152 original_key: original_key.to_string(),
153 },
154 );
155 Ok(())
156 }
157}
158
159pub(crate) fn amend_frontmatter_for_duplicate(
174 canonical_path: &Path,
175 tag: &str,
176 occurrence: u32,
177) -> Result<bool, String> {
178 let contents = fs::read_to_string(canonical_path)
179 .map_err(|err| format!("failed to read {}: {err}", canonical_path.display()))?;
180 let had_trailing_newline = contents.ends_with('\n');
181 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
182
183 if lines.first().map(String::as_str) != Some("---") {
184 return Err(format!(
185 "{} does not start with a frontmatter delimiter",
186 canonical_path.display()
187 ));
188 }
189 let Some(mut close_idx) = lines
190 .iter()
191 .skip(1)
192 .position(|line| line == "---")
193 .map(|i| i + 1)
194 else {
195 return Err(format!(
196 "{} has no closing frontmatter delimiter",
197 canonical_path.display()
198 ));
199 };
200
201 let Some(tags_idx) = lines[1..close_idx]
202 .iter()
203 .position(|line| line == "tags:")
204 .map(|i| i + 1)
205 else {
206 return Err(format!("{} has no tags: field", canonical_path.display()));
207 };
208 let mut tags_block_end = lines[tags_idx + 1..close_idx]
209 .iter()
210 .take_while(|line| line.starts_with(" - "))
211 .count()
212 + tags_idx
213 + 1;
214
215 let mut changed = false;
216 let tag_line = format!(" - {tag}");
217 let has_tag = lines[tags_idx + 1..tags_block_end]
218 .iter()
219 .any(|line| line == &tag_line);
220 if !has_tag {
221 lines.insert(tags_block_end, tag_line);
222 tags_block_end += 1;
223 close_idx += 1;
224 changed = true;
225 }
226
227 let also_in_header = "also-in:";
228 let existing_also_in_idx = lines[tags_block_end..close_idx]
229 .iter()
230 .position(|line| line == also_in_header)
231 .map(|i| i + tags_block_end);
232
233 let new_entry_prefix = format!(" - {tag}#");
234 let new_entry = format!(" - {tag}#{occurrence}");
235
236 match existing_also_in_idx {
237 Some(also_in_idx) => {
238 let also_in_block_end = lines[also_in_idx + 1..close_idx]
239 .iter()
240 .take_while(|line| line.starts_with(" - "))
241 .count()
242 + also_in_idx
243 + 1;
244 let existing_entry_idx = lines[also_in_idx + 1..also_in_block_end]
245 .iter()
246 .position(|line| line.starts_with(&new_entry_prefix))
247 .map(|i| i + also_in_idx + 1);
248 match existing_entry_idx {
249 Some(idx) => {
250 if lines[idx] != new_entry {
251 lines[idx] = new_entry;
252 changed = true;
253 }
254 }
255 None => {
256 lines.insert(also_in_block_end, new_entry);
257 changed = true;
258 }
259 }
260 }
261 None => {
262 lines.insert(tags_block_end, also_in_header.to_string());
263 lines.insert(tags_block_end + 1, new_entry);
264 changed = true;
265 }
266 }
267
268 if !changed {
269 return Ok(false);
270 }
271
272 let mut rewritten = lines.join("\n");
273 if had_trailing_newline {
274 rewritten.push('\n');
275 }
276 fs::write(canonical_path, rewritten)
277 .map_err(|err| format!("failed to write {}: {err}", canonical_path.display()))?;
278 Ok(true)
279}
280
281pub(crate) fn rewrite_attachment_reference(
295 md_path: &Path,
296 old_relpath: &str,
297 new_relpath: &str,
298) -> Result<bool, String> {
299 let contents = fs::read_to_string(md_path)
300 .map_err(|err| format!("failed to read {}: {err}", md_path.display()))?;
301 let had_trailing_newline = contents.ends_with('\n');
302 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
303
304 let old_line = format!(" - {old_relpath}");
305 let Some(idx) = lines.iter().position(|line| line == &old_line) else {
306 return Ok(false);
307 };
308 lines[idx] = format!(" - {new_relpath}");
309
310 let mut rewritten = lines.join("\n");
311 if had_trailing_newline {
312 rewritten.push('\n');
313 }
314 fs::write(md_path, rewritten)
315 .map_err(|err| format!("failed to write {}: {err}", md_path.display()))?;
316 Ok(true)
317}
318
319pub(crate) fn yaml_quote(s: &str) -> String {
327 let mut escaped = String::with_capacity(s.len());
328 for c in s.chars() {
329 match c {
330 '\\' => escaped.push_str("\\\\"),
331 '"' => escaped.push_str("\\\""),
332 '\n' => escaped.push_str("\\n"),
333 '\r' => escaped.push_str("\\r"),
334 '\t' => escaped.push_str("\\t"),
335 c if (c as u32) < 0x20 || c as u32 == 0x7f => {
336 escaped.push_str(&format!("\\x{:02x}", c as u32));
337 }
338 c => escaped.push(c),
339 }
340 }
341 format!("\"{escaped}\"")
342}
343
344pub(crate) fn unique_path(desired: &Path) -> PathBuf {
347 if !desired.exists() {
348 return desired.to_path_buf();
349 }
350 let stem = desired
351 .file_stem()
352 .and_then(|stem| stem.to_str())
353 .unwrap_or("file");
354 let ext = desired.extension().and_then(|ext| ext.to_str());
355 let parent = desired.parent().unwrap_or_else(|| Path::new(""));
356
357 let mut n = 2;
358 loop {
359 let candidate_name = match ext {
360 Some(ext) => format!("{stem}-{n}.{ext}"),
361 None => format!("{stem}-{n}"),
362 };
363 let candidate = parent.join(candidate_name);
364 if !candidate.exists() {
365 return candidate;
366 }
367 n += 1;
368 }
369}
370
371const MAX_FILENAME_LENGTH: usize = 100;
375
376pub(crate) fn sanitize_filename(name: &str) -> String {
385 let base = match Path::new(name).file_name().and_then(|f| f.to_str()) {
386 Some(base) if !base.is_empty() => base,
387 _ => return "file".to_string(),
388 };
389 truncate_preserving_extension(base, MAX_FILENAME_LENGTH)
390}
391
392fn truncate_preserving_extension(name: &str, max_len: usize) -> String {
398 if name.len() <= max_len {
399 return name.to_string();
400 }
401 if let Some((stem, ext)) = name.rsplit_once('.')
402 && !ext.is_empty()
403 && ext.len() + 1 < max_len
404 {
405 return format!(
406 "{}.{ext}",
407 truncate_at_char_boundary(stem, max_len - ext.len() - 1)
408 );
409 }
410 truncate_at_char_boundary(name, max_len)
411}
412
413fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> String {
414 let mut end = max_bytes.min(s.len());
415 while end > 0 && !s.is_char_boundary(end) {
416 end -= 1;
417 }
418 s[..end].to_string()
419}
420
421pub(crate) fn collect_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
427 let mut files = Vec::new();
428 if dir.exists() {
429 visit_dir(dir, &mut files)?;
430 }
431 files.sort();
432 Ok(files)
433}
434
435fn visit_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
436 let entries =
437 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
438 for entry in entries {
439 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
440 let path = entry.path();
441 if path.is_dir() {
442 visit_dir(&path, files)?;
443 } else {
444 files.push(path);
445 }
446 }
447 Ok(())
448}
449
450pub(crate) fn after_zip_separator(key: &str) -> &str {
461 key.rsplit('!').next().unwrap_or(key)
462}
463
464pub(crate) fn extension_of(key: &str) -> String {
469 Path::new(after_zip_separator(key))
470 .extension()
471 .and_then(|ext| ext.to_str())
472 .map(|ext| ext.to_ascii_lowercase())
473 .unwrap_or_else(|| "(none)".to_string())
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn extension_of_lowercases_and_strips_the_dot() {
482 assert_eq!(extension_of("Photos/IMG_0001.JPG"), "jpg");
483 }
484
485 #[test]
486 fn extension_of_handles_no_extension() {
487 assert_eq!(extension_of("Photos/README"), "(none)");
488 }
489
490 #[test]
491 fn extension_of_handles_dotfiles_without_extension() {
492 assert_eq!(extension_of(".DS_Store"), "(none)");
493 }
494
495 #[test]
496 fn extension_of_strips_a_zip_member_prefix_before_deriving_the_extension() {
497 assert_eq!(extension_of("outer.zip!README"), "(none)");
498 assert_eq!(extension_of("outer.zip!photos/img.JPG"), "jpg");
499 }
500
501 #[test]
502 fn after_zip_separator_returns_the_segment_after_the_last_bang() {
503 assert_eq!(after_zip_separator("outer.zip!README"), "README");
504 assert_eq!(
505 after_zip_separator("a.zip!nested.zip!photos/img.jpg"),
506 "photos/img.jpg"
507 );
508 assert_eq!(after_zip_separator("plain/key.jpg"), "plain/key.jpg");
509 }
510
511 #[test]
512 fn content_index_commit_with_key_round_trips_the_original_key() {
513 let dir = tempfile::tempdir().unwrap();
514 let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
515
516 index
517 .commit_with_key("hash-a", "pdf/report.pdf", "docs/report.pdf")
518 .unwrap();
519
520 assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
521 assert_eq!(
522 index.check_with_original_key("hash-a"),
523 Some(("pdf/report.pdf", "docs/report.pdf"))
524 );
525
526 let reloaded = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
528 assert_eq!(
529 reloaded.check_with_original_key("hash-a"),
530 Some(("pdf/report.pdf", "docs/report.pdf"))
531 );
532 }
533
534 #[test]
535 fn content_index_commit_writes_an_empty_original_key() {
536 let dir = tempfile::tempdir().unwrap();
537 let mut index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
538
539 index.commit("hash-a", "pdf/report.pdf").unwrap();
540
541 assert_eq!(
542 index.check_with_original_key("hash-a"),
543 Some(("pdf/report.pdf", ""))
544 );
545 }
546
547 #[test]
548 fn content_index_load_is_backward_compatible_with_the_old_space_separated_format() {
549 let dir = tempfile::tempdir().unwrap();
550 std::fs::write(
551 dir.path().join(".content-hashes"),
552 "hash-a pdf/report.pdf\n",
553 )
554 .unwrap();
555
556 let index = ContentIndex::load(dir.path(), ".content-hashes").unwrap();
557
558 assert_eq!(index.check("hash-a"), Some("pdf/report.pdf"));
559 assert_eq!(
560 index.check_with_original_key("hash-a"),
561 Some(("pdf/report.pdf", ""))
562 );
563 }
564
565 const MESSAGE_HASHES: &str = ".message-hashes";
566 const ATTACHMENT_HASHES: &str = ".attachment-hashes";
567
568 const FIXTURE: &str = "---\n\
569 from: \"Jane Doe <jane.doe@example.com>\"\n\
570 to: \"first.last@example.com\"\n\
571 subject: \"Hello, World!\"\n\
572 date: 2024-01-26T09:15:00+00:00\n\
573 tags:\n\
574 \x20\x20- mailbox/inbox\n\
575 \x20\x20- identity/first-last\n\
576 attachments:\n\
577 \x20\x20- attachments/2024-01-26-hello-world-bingo.pdf\n\
578 uid: 482\n\
579 ---\n\
580 \n\
581 Hello there!\n";
582
583 #[test]
584 fn load_missing_file_is_empty() {
585 let dir = tempfile::tempdir().unwrap();
586 let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
587 assert!(index.check("abc").is_none());
588 }
589
590 #[test]
591 fn commit_then_check_round_trips() {
592 let dir = tempfile::tempdir().unwrap();
593 let mut index = ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap();
594 index.commit("hash1", "identity/attachments/a.pdf").unwrap();
595 assert_eq!(index.check("hash1"), Some("identity/attachments/a.pdf"));
596 assert!(index.check("hash2").is_none());
597 }
598
599 #[test]
600 fn load_picks_up_entries_committed_by_a_prior_load() {
601 let dir = tempfile::tempdir().unwrap();
602 let mut first = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
603 first.commit("hash1", "identity/a.md").unwrap();
604
605 let second = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
606 assert_eq!(second.check("hash1"), Some("identity/a.md"));
607 }
608
609 #[test]
610 fn load_skips_malformed_lines() {
611 let dir = tempfile::tempdir().unwrap();
612 fs::write(dir.path().join(MESSAGE_HASHES), "no-space-here\n").unwrap();
613 let index = ContentIndex::load(dir.path(), MESSAGE_HASHES).unwrap();
614 assert!(index.check("no-space-here").is_none());
615 }
616
617 #[test]
618 fn amend_adds_new_tag_and_also_in_entry() {
619 let dir = tempfile::tempdir().unwrap();
620 let path = dir.path().join("canonical.md");
621 fs::write(&path, FIXTURE).unwrap();
622
623 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
624 assert!(changed);
625
626 let contents = fs::read_to_string(&path).unwrap();
627 assert!(contents.contains(" - mailbox/archive\n"));
628 assert!(contents.contains("also-in:\n - mailbox/archive#45\n"));
629 assert!(contents.contains("from: \"Jane Doe <jane.doe@example.com>\""));
630 assert!(
631 contents.contains("attachments:\n - attachments/2024-01-26-hello-world-bingo.pdf")
632 );
633 assert!(contents.contains("uid: 482"));
634 assert!(contents.ends_with("Hello there!\n"));
635 }
636
637 #[test]
638 fn amend_is_idempotent_for_identical_tag_and_occurrence() {
639 let dir = tempfile::tempdir().unwrap();
640 let path = dir.path().join("canonical.md");
641 fs::write(&path, FIXTURE).unwrap();
642
643 amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
644 let after_first = fs::read_to_string(&path).unwrap();
645
646 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
647 assert!(!changed);
648 assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
649 }
650
651 #[test]
652 fn amend_updates_occurrence_in_place() {
653 let dir = tempfile::tempdir().unwrap();
654 let path = dir.path().join("canonical.md");
655 fs::write(&path, FIXTURE).unwrap();
656
657 amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).unwrap();
658 let changed = amend_frontmatter_for_duplicate(&path, "mailbox/archive", 99).unwrap();
659 assert!(changed);
660
661 let contents = fs::read_to_string(&path).unwrap();
662 assert!(contents.contains(" - mailbox/archive#99"));
663 assert!(!contents.contains(" - mailbox/archive#45"));
664 assert_eq!(contents.matches("mailbox/archive#").count(), 1);
665 }
666
667 #[test]
668 fn amend_errors_on_missing_canonical_file() {
669 let dir = tempfile::tempdir().unwrap();
670 let path = dir.path().join("does-not-exist.md");
671 assert!(amend_frontmatter_for_duplicate(&path, "mailbox/archive", 45).is_err());
672 }
673
674 #[test]
675 fn rewrite_attachment_reference_replaces_matching_line() {
676 let dir = tempfile::tempdir().unwrap();
677 let path = dir.path().join("canonical.md");
678 fs::write(&path, FIXTURE).unwrap();
679
680 let changed = rewrite_attachment_reference(
681 &path,
682 "attachments/2024-01-26-hello-world-bingo.pdf",
683 "attachments/2024-01-26-hello-world-bingo-2.pdf",
684 )
685 .unwrap();
686 assert!(changed);
687
688 let contents = fs::read_to_string(&path).unwrap();
689 assert!(
690 contents.contains("attachments:\n - attachments/2024-01-26-hello-world-bingo-2.pdf")
691 );
692 assert!(!contents.contains("attachments/2024-01-26-hello-world-bingo.pdf\n"));
693 }
694
695 #[test]
696 fn rewrite_attachment_reference_is_idempotent_when_old_path_already_gone() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("canonical.md");
699 fs::write(&path, FIXTURE).unwrap();
700
701 rewrite_attachment_reference(
702 &path,
703 "attachments/2024-01-26-hello-world-bingo.pdf",
704 "attachments/canonical.pdf",
705 )
706 .unwrap();
707 let after_first = fs::read_to_string(&path).unwrap();
708
709 let changed = rewrite_attachment_reference(
710 &path,
711 "attachments/2024-01-26-hello-world-bingo.pdf",
712 "attachments/canonical.pdf",
713 )
714 .unwrap();
715 assert!(!changed);
716 assert_eq!(fs::read_to_string(&path).unwrap(), after_first);
717 }
718
719 #[test]
720 fn rewrite_attachment_reference_errors_on_missing_file() {
721 let dir = tempfile::tempdir().unwrap();
722 let path = dir.path().join("does-not-exist.md");
723 assert!(
724 rewrite_attachment_reference(&path, "attachments/a.pdf", "attachments/b.pdf").is_err()
725 );
726 }
727
728 #[test]
729 fn commit_survives_concurrent_access_from_multiple_threads() {
730 use std::sync::{Arc, Mutex};
731 use std::thread;
732
733 let dir = tempfile::tempdir().unwrap();
734 let index = Arc::new(Mutex::new(
735 ContentIndex::load(dir.path(), ATTACHMENT_HASHES).unwrap(),
736 ));
737
738 let handles: Vec<_> = (0..8)
739 .map(|n| {
740 let index = Arc::clone(&index);
741 thread::spawn(move || {
742 index
743 .lock()
744 .unwrap()
745 .commit(&format!("hash{n}"), &format!("identity/a{n}.pdf"))
746 .unwrap();
747 })
748 })
749 .collect();
750 for handle in handles {
751 handle.join().unwrap();
752 }
753
754 let guard = index.lock().unwrap();
755 for n in 0..8 {
756 assert_eq!(
757 guard.check(&format!("hash{n}")),
758 Some(format!("identity/a{n}.pdf")).as_deref()
759 );
760 }
761
762 let contents = fs::read_to_string(dir.path().join(ATTACHMENT_HASHES)).unwrap();
763 assert_eq!(contents.lines().count(), 8);
764 }
765
766 #[test]
767 fn yaml_quote_escapes_quotes_and_backslashes() {
768 assert_eq!(yaml_quote("Hello: World"), "\"Hello: World\"");
769 assert_eq!(yaml_quote(r#"She said "hi""#), r#""She said \"hi\"""#);
770 }
771
772 #[test]
773 fn yaml_quote_escapes_embedded_newline_and_carriage_return() {
774 assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
775 assert_eq!(yaml_quote("a\r\nb"), "\"a\\r\\nb\"");
776 }
777
778 #[test]
779 fn yaml_quote_escapes_other_control_characters() {
780 assert_eq!(yaml_quote("a\tb"), "\"a\\tb\"");
781 assert_eq!(yaml_quote("a\x01b"), "\"a\\x01b\"");
782 }
783
784 #[test]
785 fn yaml_quote_leaves_non_ascii_names_unchanged() {
786 assert_eq!(yaml_quote("José García"), "\"José García\"");
787 }
788
789 #[test]
790 fn yaml_quote_prevents_frontmatter_line_injection() {
791 let malicious = "Evil\ntags:\n - admin";
796 assert!(!yaml_quote(malicious).contains('\n'));
797 }
798
799 #[test]
800 fn unique_path_returns_original_when_free() {
801 let dir = tempfile::tempdir().unwrap();
802 let desired = dir.path().join("2024-01-26-hello.md");
803 assert_eq!(unique_path(&desired), desired);
804 }
805
806 #[test]
807 fn unique_path_suffixes_on_collision() {
808 let dir = tempfile::tempdir().unwrap();
809 let desired = dir.path().join("2024-01-26-hello.md");
810 fs::write(&desired, b"").unwrap();
811
812 let resolved = unique_path(&desired);
813 assert_eq!(resolved, dir.path().join("2024-01-26-hello-2.md"));
814 }
815
816 #[test]
817 fn sanitize_filename_strips_leading_slash() {
818 assert_eq!(sanitize_filename("/img0.png"), "img0.png");
819 }
820
821 #[test]
822 fn sanitize_filename_strips_nested_directories() {
823 assert_eq!(sanitize_filename("a/b/c.pdf"), "c.pdf");
824 }
825
826 #[test]
827 fn sanitize_filename_preserves_normal_name() {
828 assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
829 }
830
831 #[test]
832 fn sanitize_filename_falls_back_for_dot_dot() {
833 assert_eq!(sanitize_filename(".."), "file");
834 }
835
836 #[test]
837 fn sanitize_filename_falls_back_for_bare_slash() {
838 assert_eq!(sanitize_filename("/"), "file");
839 }
840
841 #[test]
842 fn sanitize_filename_truncates_long_name_preserving_extension() {
843 let long_name = format!("{}.pdf", "a".repeat(300));
844 let sanitized = sanitize_filename(&long_name);
845 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
846 assert!(sanitized.ends_with(".pdf"));
847 }
848
849 #[test]
850 fn sanitize_filename_truncates_long_name_with_no_extension() {
851 let long_name = "a".repeat(300);
852 let sanitized = sanitize_filename(&long_name);
853 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
854 }
855
856 #[test]
857 fn sanitize_filename_truncates_multibyte_name_at_char_boundary() {
858 let long_name = format!("{}.png", "é".repeat(200));
861 let sanitized = sanitize_filename(&long_name);
862 assert!(sanitized.len() <= MAX_FILENAME_LENGTH);
863 assert!(sanitized.ends_with(".png"));
864 assert!(sanitized.is_char_boundary(sanitized.len()));
865 }
866
867 #[test]
868 fn collect_files_walks_nested_directories() {
869 let dir = tempfile::tempdir().unwrap();
870 fs::create_dir_all(dir.path().join("attachments")).unwrap();
871 fs::write(dir.path().join("hello.md"), b"hi").unwrap();
872 fs::write(dir.path().join("attachments/a.pdf"), b"pdf").unwrap();
873
874 let mut files = collect_files(dir.path()).unwrap();
875 files.sort();
876
877 let mut expected = vec![
878 dir.path().join("attachments/a.pdf"),
879 dir.path().join("hello.md"),
880 ];
881 expected.sort();
882
883 assert_eq!(files, expected);
884 }
885
886 #[test]
887 fn collect_files_missing_directory_is_empty() {
888 let dir = tempfile::tempdir().unwrap();
889 let missing = dir.path().join("does-not-exist");
890 assert!(collect_files(&missing).unwrap().is_empty());
891 }
892}