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