mdvault_core/domain/
services.rs1use std::fs;
7use std::path::Path;
8
9use chrono::Local;
10
11use crate::config::types::ResolvedConfig;
12
13pub struct DailyLogService;
15
16impl DailyLogService {
17 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 let daily_path = config.vault_root.join(format!("Journal/Daily/{}.md", today));
40
41 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 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 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 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 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 if let Some(log_pos) = content.find("## Log") {
79 let after_log = &content[log_pos + 6..]; 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 content.insert_str(insert_pos, &format!("\n{}", log_entry));
89 } else {
90 content.push_str(&format!("\n## Log\n{}", log_entry));
92 }
93
94 fs::write(&daily_path, &content)
96 .map_err(|e| format!("Could not write daily note: {e}"))?;
97
98 Ok(())
99 }
100}
101
102pub struct ProjectLogService;
104
105impl ProjectLogService {
106 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..]; 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 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 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 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 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}