1use 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 let year = &today[..4];
38
39 let daily_path =
41 config.vault_root.join(format!("Journal/{}/Daily/{}.md", year, today));
42
43 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 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 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 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 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 if let Some(log_pos) = content.find("## Log") {
81 let after_log = &content[log_pos + 6..]; 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 content.insert_str(insert_pos, &format!("\n{}", log_entry));
91 } else {
92 content.push_str(&format!("\n## Log\n{}", log_entry));
94 }
95
96 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 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
182pub struct ProjectLogService;
184
185impl ProjectLogService {
186 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..]; 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 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 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 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 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}