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
102/// Service for logging events to project notes.
103pub struct ProjectLogService;
104
105impl ProjectLogService {
106    /// Append a log entry to a project note's "## Logs" section.
107    pub fn log_entry(project_file: &Path, message: &str) -> Result<(), String> {
108        let today = Local::now().format("%Y-%m-%d").to_string();
109        let time = Local::now().format("%H:%M").to_string();
110
111        let content = fs::read_to_string(project_file)
112            .map_err(|e| format!("Could not read project note: {e}"))?;
113
114        let log_entry = format!("- [[{}]] - {}: {}\n", today, time, message);
115
116        let new_content = if let Some(log_pos) = content.find("## Logs") {
117            let after_log = &content[log_pos + 7..]; // Skip "## Logs"
118            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
119                log_pos + 7 + next_section
120            } else {
121                content.len()
122            };
123            let mut c = content.clone();
124            c.insert_str(insert_pos, &format!("\n{}", log_entry));
125            c
126        } else {
127            format!("{}\n## Logs\n{}", content, log_entry)
128        };
129
130        fs::write(project_file, &new_content)
131            .map_err(|e| format!("Could not write project note: {e}"))?;
132
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::path::PathBuf;
141    use tempfile::tempdir;
142
143    fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
144        ResolvedConfig {
145            active_profile: "test".into(),
146            vault_root: vault_root.clone(),
147            templates_dir: vault_root.join(".mdvault/templates"),
148            captures_dir: vault_root.join(".mdvault/captures"),
149            macros_dir: vault_root.join(".mdvault/macros"),
150            typedefs_dir: vault_root.join(".mdvault/typedefs"),
151            excluded_folders: vec![],
152            security: Default::default(),
153            logging: Default::default(),
154            activity: Default::default(),
155        }
156    }
157
158    #[test]
159    fn test_log_creation_creates_daily_note() {
160        let tmp = tempdir().unwrap();
161        let config = make_test_config(tmp.path().to_path_buf());
162        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
163
164        // Create the task file so strip_prefix works
165        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
166        fs::write(&output_path, "test").unwrap();
167
168        let result = DailyLogService::log_creation(
169            &config,
170            "task",
171            "Test Task",
172            "TST-001",
173            &output_path,
174        );
175
176        assert!(result.is_ok());
177
178        // Check daily note was created
179        let today = Local::now().format("%Y-%m-%d").to_string();
180        let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
181        assert!(daily_path.exists());
182
183        let content = fs::read_to_string(&daily_path).unwrap();
184        assert!(content.contains("type: daily"));
185        assert!(content.contains("## Log"));
186        assert!(content.contains("Created task TST-001"));
187        assert!(content.contains("[[TST-001|Test Task]]"));
188    }
189
190    #[test]
191    fn test_log_creation_appends_to_existing() {
192        let tmp = tempdir().unwrap();
193        let config = make_test_config(tmp.path().to_path_buf());
194
195        // Create existing daily note
196        let today = Local::now().format("%Y-%m-%d").to_string();
197        let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
198        fs::create_dir_all(daily_path.parent().unwrap()).unwrap();
199        fs::write(
200            &daily_path,
201            "---\ntype: daily\n---\n\n# Today\n\n## Log\n- Existing entry\n",
202        )
203        .unwrap();
204
205        let output_path = tmp.path().join("Projects/NEW/NEW-001.md");
206        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
207        fs::write(&output_path, "test").unwrap();
208
209        let result = DailyLogService::log_creation(
210            &config,
211            "project",
212            "New Project",
213            "NEW",
214            &output_path,
215        );
216
217        assert!(result.is_ok());
218
219        let content = fs::read_to_string(&daily_path).unwrap();
220        assert!(content.contains("- Existing entry"));
221        assert!(content.contains("Created project NEW"));
222    }
223
224    #[test]
225    fn test_project_log_appends_to_existing_logs_section() {
226        let tmp = tempdir().unwrap();
227        let project_file = tmp.path().join("project.md");
228        fs::write(&project_file, "---\ntitle: Test\n---\n\n## Logs\n- Existing log\n")
229            .unwrap();
230
231        let result = ProjectLogService::log_entry(
232            &project_file,
233            "Created task [[TST-001]]: Fix bug",
234        );
235        assert!(result.is_ok());
236
237        let content = fs::read_to_string(&project_file).unwrap();
238        assert!(content.contains("- Existing log"));
239        assert!(content.contains("Created task [[TST-001]]: Fix bug"));
240        // Should still have the Logs heading
241        assert!(content.contains("## Logs"));
242    }
243
244    #[test]
245    fn test_project_log_creates_logs_section_if_missing() {
246        let tmp = tempdir().unwrap();
247        let project_file = tmp.path().join("project.md");
248        fs::write(&project_file, "---\ntitle: Test\n---\n\nSome content\n").unwrap();
249
250        let result = ProjectLogService::log_entry(
251            &project_file,
252            "Created task [[TST-002]]: New feature",
253        );
254        assert!(result.is_ok());
255
256        let content = fs::read_to_string(&project_file).unwrap();
257        assert!(content.contains("## Logs"));
258        assert!(content.contains("Created task [[TST-002]]: New feature"));
259        assert!(content.contains("Some content"));
260    }
261
262    #[test]
263    fn test_project_log_preserves_sections_after_logs() {
264        let tmp = tempdir().unwrap();
265        let project_file = tmp.path().join("project.md");
266        fs::write(
267            &project_file,
268            "---\ntitle: Test\n---\n\n## Logs\n- Old entry\n\n## Notes\nSome notes\n",
269        )
270        .unwrap();
271
272        let result = ProjectLogService::log_entry(
273            &project_file,
274            "Created task [[TST-003]]: Refactor",
275        );
276        assert!(result.is_ok());
277
278        let content = fs::read_to_string(&project_file).unwrap();
279        assert!(content.contains("- Old entry"));
280        assert!(content.contains("Created task [[TST-003]]: Refactor"));
281        assert!(content.contains("## Notes"));
282        assert!(content.contains("Some notes"));
283    }
284}