Skip to main content

mdvault_core/domain/
services.rs

1//! Domain services for note lifecycle operations.
2//!
3//! These services handle cross-cutting concerns like daily logging
4//! that can be used by multiple behaviors.
5
6use std::fs;
7use std::path::Path;
8
9use chrono::Local;
10
11use crate::config::types::ResolvedConfig;
12
13/// Service for logging note creation events to daily notes.
14pub struct DailyLogService;
15
16impl DailyLogService {
17    /// Log a creation event to today's daily note.
18    ///
19    /// Creates the daily note if it doesn't exist. The log entry includes
20    /// a wikilink to the created note.
21    ///
22    /// # Arguments
23    /// * `config` - Resolved vault configuration
24    /// * `note_type` - Type of note created (e.g., "task", "project")
25    /// * `title` - Title of the created note
26    /// * `note_id` - ID of the note (e.g., "TST-001"), can be empty
27    /// * `output_path` - Path where the note was written
28    pub fn log_creation(
29        config: &ResolvedConfig,
30        note_type: &str,
31        title: &str,
32        note_id: &str,
33        output_path: &Path,
34    ) -> Result<(), String> {
35        let today = Local::now().format("%Y-%m-%d").to_string();
36        let time = Local::now().format("%H:%M").to_string();
37        let year = &today[..4];
38
39        // Build daily note path (default pattern: Journal/{year}/Daily/YYYY-MM-DD.md)
40        let daily_path =
41            config.vault_root.join(format!("Journal/{}/Daily/{}.md", year, today));
42
43        // Ensure parent directory exists
44        if let Some(parent) = daily_path.parent() {
45            fs::create_dir_all(parent)
46                .map_err(|e| format!("Could not create daily directory: {e}"))?;
47        }
48
49        // Read or create daily note
50        let mut content = match fs::read_to_string(&daily_path) {
51            Ok(c) => c,
52            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
53                // Create minimal daily note
54                let content = format!(
55                    "---\ntype: daily\ndate: {}\n---\n\n# {}\n\n## Log\n",
56                    today, today
57                );
58                fs::write(&daily_path, &content)
59                    .map_err(|e| format!("Could not create daily note: {e}"))?;
60                content
61            }
62            Err(e) => return Err(format!("Could not read daily note: {e}")),
63        };
64
65        // Build the log entry with link to the note
66        let rel_path =
67            output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
68        let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
69
70        // Format: "- **HH:MM**: Created task TST-001: [[TST-001|Title]]"
71        let id_display =
72            if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
73
74        let log_entry = format!(
75            "- **{}**: Created {}{}: [[{}|{}]]\n",
76            time, note_type, id_display, link, title
77        );
78
79        // Find the Log section and append, or append at end
80        if let Some(log_pos) = content.find("## Log") {
81            // Find the end of the Log section (next ## or end of file)
82            let after_log = &content[log_pos + 6..]; // Skip "## Log"
83            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
84                log_pos + 6 + next_section
85            } else {
86                content.len()
87            };
88
89            // Insert the log entry after a newline
90            content.insert_str(insert_pos, &format!("\n{}", log_entry));
91        } else {
92            // No Log section, add one
93            content.push_str(&format!("\n## Log\n{}", log_entry));
94        }
95
96        // Write back
97        fs::write(&daily_path, &content)
98            .map_err(|e| format!("Could not write daily note: {e}"))?;
99
100        Ok(())
101    }
102}
103
104impl DailyLogService {
105    /// Log a generic event to today's daily note.
106    ///
107    /// Used for task completion, cancellation, and other lifecycle events
108    /// that should appear in the daily journal.
109    ///
110    /// # Arguments
111    /// * `config` - Resolved vault configuration
112    /// * `action` - Action verb (e.g., "Completed", "Cancelled")
113    /// * `note_type` - Type of note (e.g., "task")
114    /// * `title` - Title of the note
115    /// * `note_id` - ID of the note (e.g., "TST-001"), can be empty
116    /// * `output_path` - Path to the note file
117    pub fn log_event(
118        config: &ResolvedConfig,
119        action: &str,
120        note_type: &str,
121        title: &str,
122        note_id: &str,
123        output_path: &Path,
124    ) -> Result<(), String> {
125        let today = Local::now().format("%Y-%m-%d").to_string();
126        let time = Local::now().format("%H:%M").to_string();
127        let year = &today[..4];
128
129        let daily_path =
130            config.vault_root.join(format!("Journal/{}/Daily/{}.md", year, today));
131
132        if let Some(parent) = daily_path.parent() {
133            fs::create_dir_all(parent)
134                .map_err(|e| format!("Could not create daily directory: {e}"))?;
135        }
136
137        let mut content = match fs::read_to_string(&daily_path) {
138            Ok(c) => c,
139            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
140                let content = format!(
141                    "---\ntype: daily\ndate: {}\n---\n\n# {}\n\n## Log\n",
142                    today, today
143                );
144                fs::write(&daily_path, &content)
145                    .map_err(|e| format!("Could not create daily note: {e}"))?;
146                content
147            }
148            Err(e) => return Err(format!("Could not read daily note: {e}")),
149        };
150
151        let rel_path =
152            output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
153        let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
154
155        let id_display =
156            if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
157
158        let log_entry = format!(
159            "- **{}**: {} {}{}: [[{}|{}]]\n",
160            time, action, note_type, id_display, link, title
161        );
162
163        if let Some(log_pos) = content.find("## Log") {
164            let after_log = &content[log_pos + 6..];
165            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
166                log_pos + 6 + next_section
167            } else {
168                content.len()
169            };
170            content.insert_str(insert_pos, &format!("\n{}", log_entry));
171        } else {
172            content.push_str(&format!("\n## Log\n{}", log_entry));
173        }
174
175        fs::write(&daily_path, &content)
176            .map_err(|e| format!("Could not write daily note: {e}"))?;
177
178        Ok(())
179    }
180}
181
182/// Service for logging events to project notes.
183pub struct ProjectLogService;
184
185impl ProjectLogService {
186    /// Append a log entry to a project note's "## Logs" section.
187    pub fn log_entry(project_file: &Path, message: &str) -> Result<(), String> {
188        let today = Local::now().format("%Y-%m-%d").to_string();
189        let time = Local::now().format("%H:%M").to_string();
190
191        let content = fs::read_to_string(project_file)
192            .map_err(|e| format!("Could not read project note: {e}"))?;
193
194        let log_entry = format!("- [[{}]] - {}: {}\n", today, time, message);
195
196        let new_content = if let Some(log_pos) = content.find("## Logs") {
197            let after_log = &content[log_pos + 7..]; // Skip "## Logs"
198            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
199                log_pos + 7 + next_section
200            } else {
201                content.len()
202            };
203            let mut c = content.clone();
204            c.insert_str(insert_pos, &format!("\n{}", log_entry));
205            c
206        } else {
207            format!("{}\n## Logs\n{}", content, log_entry)
208        };
209
210        fs::write(project_file, &new_content)
211            .map_err(|e| format!("Could not write project note: {e}"))?;
212
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::path::PathBuf;
221    use tempfile::tempdir;
222
223    fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
224        ResolvedConfig {
225            active_profile: "test".into(),
226            vault_root: vault_root.clone(),
227            templates_dir: vault_root.join(".mdvault/templates"),
228            captures_dir: vault_root.join(".mdvault/captures"),
229            macros_dir: vault_root.join(".mdvault/macros"),
230            typedefs_dir: vault_root.join(".mdvault/typedefs"),
231            typedefs_fallback_dir: None,
232            excluded_folders: vec![],
233            security: Default::default(),
234            logging: Default::default(),
235            activity: Default::default(),
236        }
237    }
238
239    #[test]
240    fn test_log_creation_creates_daily_note() {
241        let tmp = tempdir().unwrap();
242        let config = make_test_config(tmp.path().to_path_buf());
243        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
244
245        // Create the task file so strip_prefix works
246        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
247        fs::write(&output_path, "test").unwrap();
248
249        let result = DailyLogService::log_creation(
250            &config,
251            "task",
252            "Test Task",
253            "TST-001",
254            &output_path,
255        );
256
257        assert!(result.is_ok());
258
259        // Check daily note was created
260        let today = Local::now().format("%Y-%m-%d").to_string();
261        let year = &today[..4];
262        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
263        assert!(daily_path.exists());
264
265        let content = fs::read_to_string(&daily_path).unwrap();
266        assert!(content.contains("type: daily"));
267        assert!(content.contains("## Log"));
268        assert!(content.contains("Created task TST-001"));
269        assert!(content.contains("[[TST-001|Test Task]]"));
270    }
271
272    #[test]
273    fn test_log_creation_appends_to_existing() {
274        let tmp = tempdir().unwrap();
275        let config = make_test_config(tmp.path().to_path_buf());
276
277        // Create existing daily note
278        let today = Local::now().format("%Y-%m-%d").to_string();
279        let year = &today[..4];
280        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
281        fs::create_dir_all(daily_path.parent().unwrap()).unwrap();
282        fs::write(
283            &daily_path,
284            "---\ntype: daily\n---\n\n# Today\n\n## Log\n- Existing entry\n",
285        )
286        .unwrap();
287
288        let output_path = tmp.path().join("Projects/NEW/NEW-001.md");
289        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
290        fs::write(&output_path, "test").unwrap();
291
292        let result = DailyLogService::log_creation(
293            &config,
294            "project",
295            "New Project",
296            "NEW",
297            &output_path,
298        );
299
300        assert!(result.is_ok());
301
302        let content = fs::read_to_string(&daily_path).unwrap();
303        assert!(content.contains("- Existing entry"));
304        assert!(content.contains("Created project NEW"));
305    }
306
307    #[test]
308    fn test_project_log_appends_to_existing_logs_section() {
309        let tmp = tempdir().unwrap();
310        let project_file = tmp.path().join("project.md");
311        fs::write(&project_file, "---\ntitle: Test\n---\n\n## Logs\n- Existing log\n")
312            .unwrap();
313
314        let result = ProjectLogService::log_entry(
315            &project_file,
316            "Created task [[TST-001]]: Fix bug",
317        );
318        assert!(result.is_ok());
319
320        let content = fs::read_to_string(&project_file).unwrap();
321        assert!(content.contains("- Existing log"));
322        assert!(content.contains("Created task [[TST-001]]: Fix bug"));
323        // Should still have the Logs heading
324        assert!(content.contains("## Logs"));
325    }
326
327    #[test]
328    fn test_project_log_creates_logs_section_if_missing() {
329        let tmp = tempdir().unwrap();
330        let project_file = tmp.path().join("project.md");
331        fs::write(&project_file, "---\ntitle: Test\n---\n\nSome content\n").unwrap();
332
333        let result = ProjectLogService::log_entry(
334            &project_file,
335            "Created task [[TST-002]]: New feature",
336        );
337        assert!(result.is_ok());
338
339        let content = fs::read_to_string(&project_file).unwrap();
340        assert!(content.contains("## Logs"));
341        assert!(content.contains("Created task [[TST-002]]: New feature"));
342        assert!(content.contains("Some content"));
343    }
344
345    #[test]
346    fn test_log_event_completed_task() {
347        let tmp = tempdir().unwrap();
348        let config = make_test_config(tmp.path().to_path_buf());
349        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
350
351        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
352        fs::write(&output_path, "test").unwrap();
353
354        let result = DailyLogService::log_event(
355            &config,
356            "Completed",
357            "task",
358            "Fix the bug",
359            "TST-001",
360            &output_path,
361        );
362
363        assert!(result.is_ok());
364
365        let today = Local::now().format("%Y-%m-%d").to_string();
366        let year = &today[..4];
367        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
368        assert!(daily_path.exists());
369
370        let content = fs::read_to_string(&daily_path).unwrap();
371        assert!(content.contains("Completed task TST-001"));
372        assert!(content.contains("[[TST-001|Fix the bug]]"));
373    }
374
375    #[test]
376    fn test_log_event_cancelled_task() {
377        let tmp = tempdir().unwrap();
378        let config = make_test_config(tmp.path().to_path_buf());
379        let output_path = tmp.path().join("Projects/TST/Tasks/TST-002.md");
380
381        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
382        fs::write(&output_path, "test").unwrap();
383
384        let result = DailyLogService::log_event(
385            &config,
386            "Cancelled",
387            "task",
388            "Old feature",
389            "TST-002",
390            &output_path,
391        );
392
393        assert!(result.is_ok());
394
395        let today = Local::now().format("%Y-%m-%d").to_string();
396        let year = &today[..4];
397        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
398        let content = fs::read_to_string(&daily_path).unwrap();
399        assert!(content.contains("Cancelled task TST-002"));
400        assert!(content.contains("[[TST-002|Old feature]]"));
401    }
402
403    #[test]
404    fn test_project_log_preserves_sections_after_logs() {
405        let tmp = tempdir().unwrap();
406        let project_file = tmp.path().join("project.md");
407        fs::write(
408            &project_file,
409            "---\ntitle: Test\n---\n\n## Logs\n- Old entry\n\n## Notes\nSome notes\n",
410        )
411        .unwrap();
412
413        let result = ProjectLogService::log_entry(
414            &project_file,
415            "Created task [[TST-003]]: Refactor",
416        );
417        assert!(result.is_ok());
418
419        let content = fs::read_to_string(&project_file).unwrap();
420        assert!(content.contains("- Old entry"));
421        assert!(content.contains("Created task [[TST-003]]: Refactor"));
422        assert!(content.contains("## Notes"));
423        assert!(content.contains("Some notes"));
424    }
425}