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