1use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use chrono::Local;
11
12use crate::config::types::ResolvedConfig;
13
14pub 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
31pub struct DailyLogService;
33
34impl DailyLogService {
35 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 let daily_path =
59 config.vault_root.join(format!("Journal/{}/Daily/{}.md", year, today));
60
61 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 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 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 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 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 if let Some(log_pos) = content.find("## Logs") {
99 let after_log = &content[log_pos + 7..]; 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 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 content.push_str(&format!("\n## Logs\n{}", log_entry));
118 }
119
120 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 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
220pub struct ProjectLogService;
222
223impl ProjectLogService {
224 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..]; 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 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 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 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 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}