Skip to main content

mxr_compose/
attachments.rs

1use crate::frontmatter::ComposeError;
2use std::path::{Path, PathBuf};
3
4/// Resolve and validate attachment paths from frontmatter.
5/// Supports tilde expansion.
6pub fn resolve_attachments(paths: &[String]) -> Result<Vec<ResolvedAttachment>, ComposeError> {
7    paths.iter().map(|p| resolve_one_str(p)).collect()
8}
9
10pub fn resolve_attachment_paths(
11    paths: &[PathBuf],
12) -> Result<Vec<ResolvedAttachment>, ComposeError> {
13    paths
14        .iter()
15        .map(PathBuf::as_path)
16        .map(resolve_one_path)
17        .collect()
18}
19
20#[derive(Debug)]
21pub struct ResolvedAttachment {
22    pub path: PathBuf,
23    pub filename: String,
24    pub mime_type: String,
25}
26
27fn resolve_one_str(path_str: &str) -> Result<ResolvedAttachment, ComposeError> {
28    let expanded = expand_tilde(path_str);
29    let path = PathBuf::from(&expanded);
30    resolve_one_path(&path).map_err(|err| match err {
31        ComposeError::AttachmentNotFound(_) => {
32            ComposeError::AttachmentNotFound(path_str.to_string())
33        }
34        other => other,
35    })
36}
37
38fn resolve_one_path(path: &Path) -> Result<ResolvedAttachment, ComposeError> {
39    let path = path.to_path_buf();
40
41    if !path.exists() {
42        return Err(ComposeError::AttachmentNotFound(path.display().to_string()));
43    }
44
45    let filename = path
46        .file_name()
47        .map(|n| n.to_string_lossy().to_string())
48        .unwrap_or_else(|| "attachment".to_string());
49
50    let mime_type = match path.extension().and_then(|e| e.to_str()) {
51        Some("pdf") => "application/pdf",
52        Some("png") => "image/png",
53        Some("jpg" | "jpeg") => "image/jpeg",
54        Some("gif") => "image/gif",
55        Some("txt") => "text/plain",
56        Some("csv") => "text/csv",
57        Some("html" | "htm") => "text/html",
58        Some("zip") => "application/zip",
59        Some("doc") => "application/msword",
60        Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
61        Some("xls") => "application/vnd.ms-excel",
62        Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
63        _ => "application/octet-stream",
64    }
65    .to_string();
66
67    Ok(ResolvedAttachment {
68        path,
69        filename,
70        mime_type,
71    })
72}
73
74fn expand_tilde(path: &str) -> String {
75    if path.starts_with("~/") {
76        if let Some(home) = dirs::home_dir() {
77            return format!("{}{}", home.display(), &path[1..]);
78        }
79    }
80    path.to_string()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn attachment_not_found_error() {
89        let result = resolve_one_str("/nonexistent/file.pdf");
90        assert!(result.is_err());
91        match result.unwrap_err() {
92            ComposeError::AttachmentNotFound(path) => {
93                assert_eq!(path, "/nonexistent/file.pdf");
94            }
95            e => panic!("Expected AttachmentNotFound, got: {e}"),
96        }
97    }
98
99    #[test]
100    fn attachment_found_with_correct_mime() {
101        let tmp = tempfile::NamedTempFile::with_suffix(".pdf").unwrap();
102        let result = resolve_one_str(tmp.path().to_str().unwrap()).unwrap();
103        assert_eq!(result.mime_type, "application/pdf");
104    }
105
106    #[test]
107    fn tilde_expansion() {
108        let expanded = expand_tilde("~/Documents/test.txt");
109        assert!(!expanded.starts_with('~'));
110        assert!(expanded.contains("Documents/test.txt"));
111    }
112
113    #[test]
114    fn no_tilde_passthrough() {
115        let path = "/absolute/path/file.txt";
116        assert_eq!(expand_tilde(path), path);
117    }
118}