Skip to main content

mdvault_core/
paths.rs

1//! Centralised vault path resolution.
2//!
3//! All vault structure conventions live here. Behaviours, services, and CLI
4//! commands use `PathResolver` instead of hardcoded `format!()` strings.
5
6use std::path::{Path, PathBuf};
7
8/// Resolves vault paths from a root directory.
9///
10/// Lightweight borrowed struct — construct locally, use, drop.
11pub struct PathResolver<'a> {
12    vault_root: &'a Path,
13}
14
15impl<'a> PathResolver<'a> {
16    pub fn new(vault_root: &'a Path) -> Self {
17        Self { vault_root }
18    }
19
20    // ── Note paths ───────────────────────────────────────────────────────
21
22    /// `Inbox/{id}.md`
23    pub fn inbox_task(&self, id: &str) -> PathBuf {
24        self.vault_root.join(format!("Inbox/{id}.md"))
25    }
26
27    /// `Projects/{project}/Tasks/{id}.md`
28    pub fn project_task(&self, project: &str, id: &str) -> PathBuf {
29        self.vault_root.join(format!("Projects/{project}/Tasks/{id}.md"))
30    }
31
32    /// `Projects/{project}`
33    pub fn project_dir(&self, project: &str) -> PathBuf {
34        self.vault_root.join(format!("Projects/{project}"))
35    }
36
37    /// `Projects/{project}/{project}.md`
38    pub fn project_note(&self, project: &str) -> PathBuf {
39        self.vault_root.join(format!("Projects/{project}/{project}.md"))
40    }
41
42    /// `Projects/_archive/{project}/{project}.md`
43    pub fn archive_project_note(&self, project: &str) -> PathBuf {
44        self.vault_root.join(format!("Projects/_archive/{project}/{project}.md"))
45    }
46
47    /// `Journal/{year}/Daily/{date}.md` — `date` must be `YYYY-MM-DD`.
48    pub fn daily_note(&self, date: &str) -> PathBuf {
49        let year = &date[..4];
50        self.vault_root.join(format!("Journal/{year}/Daily/{date}.md"))
51    }
52
53    /// `Journal/{year}/Weekly/{week}.md` — `week` must be `YYYY-Wxx`.
54    pub fn weekly_note(&self, week: &str) -> PathBuf {
55        let year = &week[..4];
56        self.vault_root.join(format!("Journal/{year}/Weekly/{week}.md"))
57    }
58
59    /// `Meetings/{year}/{id}.md` — extracts year from `date` (`YYYY-MM-DD`).
60    pub fn meeting_note(&self, date: &str, id: &str) -> PathBuf {
61        let year = &date[..4];
62        self.vault_root.join(format!("Meetings/{year}/{id}.md"))
63    }
64
65    /// `zettels/{slug}.md`
66    pub fn zettel(&self, slug: &str) -> PathBuf {
67        self.vault_root.join(format!("zettels/{slug}.md"))
68    }
69
70    /// `{type_name}s/{slug}.md` — fallback for custom types.
71    pub fn custom_type(&self, type_name: &str, slug: &str) -> PathBuf {
72        self.vault_root.join(format!("{type_name}s/{slug}.md"))
73    }
74
75    // ── Meetings directory ───────────────────────────────────────────────
76
77    /// `Meetings/{year}` — for scanning existing meeting IDs.
78    pub fn meetings_dir(&self, year: &str) -> PathBuf {
79        self.vault_root.join(format!("Meetings/{year}"))
80    }
81
82    // ── System paths ─────────────────────────────────────────────────────
83
84    /// `.mdvault/index.db`
85    pub fn index_db(&self) -> PathBuf {
86        self.vault_root.join(".mdvault/index.db")
87    }
88
89    /// `.mdvault/state`
90    pub fn state_dir(&self) -> PathBuf {
91        self.vault_root.join(".mdvault/state")
92    }
93
94    /// `.mdvault/state/context.toml`
95    pub fn state_file(&self) -> PathBuf {
96        self.vault_root.join(".mdvault/state/context.toml")
97    }
98
99    /// `.mdvault/activity.jsonl`
100    pub fn activity_log(&self) -> PathBuf {
101        self.vault_root.join(".mdvault/activity.jsonl")
102    }
103
104    /// `.mdvault/activity_archive`
105    pub fn activity_archive_dir(&self) -> PathBuf {
106        self.vault_root.join(".mdvault/activity_archive")
107    }
108
109    // ── Path predicates ──────────────────────────────────────────────────
110
111    /// Check whether a task path belongs to a given project folder.
112    ///
113    /// Matches both active (`Projects/{folder}/`) and archived
114    /// (`Projects/_archive/{folder}/`) paths.
115    pub fn is_project_task(task_path: &str, project_folder: &str) -> bool {
116        task_path.contains(&format!("Projects/{project_folder}/"))
117            || task_path.contains(&format!("Projects/_archive/{project_folder}/"))
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::path::Path;
125
126    fn resolver() -> PathResolver<'static> {
127        PathResolver::new(Path::new("/vault"))
128    }
129
130    #[test]
131    fn inbox_task_path() {
132        assert_eq!(
133            resolver().inbox_task("INB-001"),
134            Path::new("/vault/Inbox/INB-001.md")
135        );
136    }
137
138    #[test]
139    fn project_task_path() {
140        assert_eq!(
141            resolver().project_task("my-proj", "MP-001"),
142            Path::new("/vault/Projects/my-proj/Tasks/MP-001.md")
143        );
144    }
145
146    #[test]
147    fn project_dir_path() {
148        assert_eq!(
149            resolver().project_dir("my-proj"),
150            Path::new("/vault/Projects/my-proj")
151        );
152    }
153
154    #[test]
155    fn project_note_path() {
156        assert_eq!(
157            resolver().project_note("my-proj"),
158            Path::new("/vault/Projects/my-proj/my-proj.md")
159        );
160    }
161
162    #[test]
163    fn archive_project_note_path() {
164        assert_eq!(
165            resolver().archive_project_note("old-proj"),
166            Path::new("/vault/Projects/_archive/old-proj/old-proj.md")
167        );
168    }
169
170    #[test]
171    fn daily_note_path() {
172        assert_eq!(
173            resolver().daily_note("2026-03-15"),
174            Path::new("/vault/Journal/2026/Daily/2026-03-15.md")
175        );
176    }
177
178    #[test]
179    fn weekly_note_path() {
180        assert_eq!(
181            resolver().weekly_note("2026-W13"),
182            Path::new("/vault/Journal/2026/Weekly/2026-W13.md")
183        );
184    }
185
186    #[test]
187    fn meeting_note_path() {
188        assert_eq!(
189            resolver().meeting_note("2026-01-15", "MTG-2026-01-15-001"),
190            Path::new("/vault/Meetings/2026/MTG-2026-01-15-001.md")
191        );
192    }
193
194    #[test]
195    fn zettel_path() {
196        assert_eq!(
197            resolver().zettel("my-knowledge-note"),
198            Path::new("/vault/zettels/my-knowledge-note.md")
199        );
200    }
201
202    #[test]
203    fn custom_type_path() {
204        assert_eq!(
205            resolver().custom_type("contact", "john-doe"),
206            Path::new("/vault/contacts/john-doe.md")
207        );
208    }
209
210    #[test]
211    fn index_db_path() {
212        assert_eq!(resolver().index_db(), Path::new("/vault/.mdvault/index.db"));
213    }
214
215    #[test]
216    fn state_paths() {
217        assert_eq!(resolver().state_dir(), Path::new("/vault/.mdvault/state"));
218        assert_eq!(
219            resolver().state_file(),
220            Path::new("/vault/.mdvault/state/context.toml")
221        );
222    }
223
224    #[test]
225    fn activity_paths() {
226        assert_eq!(
227            resolver().activity_log(),
228            Path::new("/vault/.mdvault/activity.jsonl")
229        );
230        assert_eq!(
231            resolver().activity_archive_dir(),
232            Path::new("/vault/.mdvault/activity_archive")
233        );
234    }
235
236    #[test]
237    fn is_project_task_active() {
238        assert!(PathResolver::is_project_task(
239            "Projects/my-proj/Tasks/MP-001.md",
240            "my-proj"
241        ));
242    }
243
244    #[test]
245    fn is_project_task_archived() {
246        assert!(PathResolver::is_project_task(
247            "Projects/_archive/my-proj/Tasks/MP-001.md",
248            "my-proj"
249        ));
250    }
251
252    #[test]
253    fn is_project_task_wrong_project() {
254        assert!(!PathResolver::is_project_task(
255            "Projects/other/Tasks/MP-001.md",
256            "my-proj"
257        ));
258    }
259
260    #[test]
261    fn is_project_task_inbox() {
262        assert!(!PathResolver::is_project_task("Inbox/INB-001.md", "my-proj"));
263    }
264
265    #[test]
266    fn meetings_dir_path() {
267        assert_eq!(resolver().meetings_dir("2026"), Path::new("/vault/Meetings/2026"));
268    }
269
270    #[test]
271    fn is_project_task_not_confused_by_substring() {
272        // "proj" should not match "my-proj"
273        assert!(!PathResolver::is_project_task(
274            "Projects/my-proj/Tasks/MP-001.md",
275            "proj"
276        ));
277    }
278}