mdvault_core/domain/behaviors/
task.rs1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use crate::types::TypeDefinition;
13
14use super::super::context::{CreationContext, FieldPrompt, PromptContext, PromptType};
15use super::super::traits::{
16 DomainError, DomainResult, NoteBehavior, NoteIdentity, NoteLifecycle, NotePrompts,
17};
18
19pub struct TaskBehavior {
21 typedef: Option<Arc<TypeDefinition>>,
22}
23
24impl TaskBehavior {
25 pub fn new(typedef: Option<Arc<TypeDefinition>>) -> Self {
27 Self { typedef }
28 }
29}
30
31impl NoteIdentity for TaskBehavior {
32 fn generate_id(&self, ctx: &CreationContext) -> DomainResult<Option<String>> {
33 if let Some(ref id) = ctx.core_metadata.task_id {
36 return Ok(Some(id.clone()));
37 }
38 Ok(None)
39 }
40
41 fn output_path(&self, ctx: &CreationContext) -> DomainResult<PathBuf> {
42 if let Some(ref td) = self.typedef
44 && let Some(ref output) = td.output
45 {
46 return super::render_output_template(output, ctx);
47 }
48
49 let task_id = ctx
51 .core_metadata
52 .task_id
53 .as_ref()
54 .ok_or_else(|| DomainError::PathResolution("task-id not set".into()))?;
55
56 let project = ctx.get_var("project").unwrap_or("inbox");
57
58 if project == "inbox" {
59 Ok(ctx.config.vault_root.join(format!("Inbox/{}.md", task_id)))
60 } else {
61 Ok(ctx
62 .config
63 .vault_root
64 .join(format!("Projects/{}/Tasks/{}.md", project, task_id)))
65 }
66 }
67
68 fn core_fields(&self) -> Vec<&'static str> {
69 vec!["type", "title", "task-id", "project"]
70 }
71}
72
73impl NoteLifecycle for TaskBehavior {
74 fn before_create(&self, ctx: &mut CreationContext) -> DomainResult<()> {
75 let project = ctx
76 .get_var("project")
77 .map(|s| s.to_string())
78 .unwrap_or_else(|| "inbox".into());
79
80 let (task_id, project) = if project == "inbox" {
82 (generate_inbox_task_id(&ctx.config.vault_root)?, project)
83 } else {
84 if let Ok(project_file) = find_project_file(ctx.config, &project)
86 && let Ok(content) = fs::read_to_string(&project_file)
87 && let Ok(parsed) = crate::frontmatter::parse(&content)
88 && let Some(ref fm) = parsed.frontmatter
89 && let Some(status) = fm.fields.get("status")
90 && status.as_str() == Some("archived")
91 {
92 return Err(DomainError::Other(format!(
93 "Cannot create task in archived project '{}'",
94 project
95 )));
96 }
97 let (project_id, counter, slug) = get_project_info(ctx.config, &project)?;
99 (format!("{}-{:03}", project_id, counter + 1), slug)
100 };
101
102 ctx.core_metadata.task_id = Some(task_id.clone());
104 ctx.core_metadata.project =
105 if project == "inbox" { None } else { Some(project.clone()) };
106 ctx.set_var("task-id", &task_id);
107 if project != "inbox" {
108 ctx.set_var("project", &project);
109 }
110
111 Ok(())
112 }
113
114 fn after_create(&self, ctx: &CreationContext, _content: &str) -> DomainResult<()> {
115 let project = ctx.get_var("project").unwrap_or("inbox");
116
117 if project != "inbox" {
119 increment_project_counter(ctx.config, project)?;
120 }
121
122 if let Some(ref output_path) = ctx.output_path {
124 let task_id = ctx.core_metadata.task_id.as_deref().unwrap_or("");
125 if let Err(e) = super::super::services::DailyLogService::log_creation(
126 ctx.config,
127 "task",
128 &ctx.title,
129 task_id,
130 output_path,
131 ) {
132 tracing::warn!("Failed to log to daily note: {}", e);
134 }
135 }
136
137 if project != "inbox"
139 && let Ok(project_file) = find_project_file(ctx.config, project)
140 {
141 let task_id = ctx.core_metadata.task_id.as_deref().unwrap_or("");
142 let message = format!("Created task [[{}]]: {}", task_id, ctx.title);
143 if let Err(e) = super::super::services::ProjectLogService::log_entry(
144 &project_file,
145 &message,
146 ) {
147 tracing::warn!("Failed to log to project note: {}", e);
148 }
149 }
150
151 Ok(())
154 }
155}
156
157impl NotePrompts for TaskBehavior {
158 fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
159 let mut prompts = vec![];
160
161 if !ctx.provided_vars.contains_key("project") && !ctx.batch_mode {
163 prompts.push(FieldPrompt {
164 field_name: "project".into(),
165 prompt_text: "Select project for this task".into(),
166 prompt_type: PromptType::ProjectSelector,
167 required: false, default_value: Some("inbox".into()),
169 });
170 }
171
172 prompts
173 }
174}
175
176impl NoteBehavior for TaskBehavior {
177 fn type_name(&self) -> &'static str {
178 "task"
179 }
180}
181
182use crate::config::types::ResolvedConfig;
185use std::fs;
186
187fn generate_inbox_task_id(vault_root: &std::path::Path) -> DomainResult<String> {
189 let inbox_dir = vault_root.join("Inbox");
190
191 let mut max_num = 0u32;
192
193 if inbox_dir.exists() {
194 for entry in fs::read_dir(&inbox_dir).map_err(DomainError::Io)? {
195 let entry = entry.map_err(DomainError::Io)?;
196 let name = entry.file_name();
197 let name_str = name.to_string_lossy();
198
199 if let Some(stem) = name_str.strip_suffix(".md")
201 && let Some(num_str) = stem.strip_prefix("INB-")
202 && let Ok(num) = num_str.parse::<u32>()
203 {
204 max_num = max_num.max(num);
205 }
206 }
207 }
208
209 Ok(format!("INB-{:03}", max_num + 1))
210}
211
212fn get_project_info(
214 config: &ResolvedConfig,
215 project: &str,
216) -> DomainResult<(String, u32, String)> {
217 let project_file = find_project_file(config, project)?;
218 let slug = extract_project_slug(&project_file, &config.vault_root);
219
220 let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
221
222 let parsed = crate::frontmatter::parse(&content).map_err(|e| {
224 DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
225 })?;
226
227 let fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
228
229 let project_id = fields
230 .get("project-id")
231 .and_then(|v| v.as_str())
232 .map(|s| s.to_string())
233 .unwrap_or_else(|| project.to_uppercase());
234
235 let counter = fields
236 .get("task_counter")
237 .and_then(|v| v.as_u64())
238 .map(|n| n as u32)
239 .unwrap_or(0);
240
241 Ok((project_id, counter, slug))
242}
243
244pub fn task_belongs_to_project(task_path: &str, project_folder: &str) -> bool {
246 task_path.contains(&format!("Projects/{}/", project_folder))
247 || task_path.contains(&format!("Projects/_archive/{}/", project_folder))
248}
249
250pub fn find_project_file(
257 config: &ResolvedConfig,
258 project: &str,
259) -> DomainResult<PathBuf> {
260 let patterns = [
262 format!("Projects/{}/{}.md", project, project),
263 format!("Projects/{}.md", project),
264 format!("projects/{}/{}.md", project.to_lowercase(), project.to_lowercase()),
265 format!("Projects/_archive/{}/{}.md", project, project),
266 ];
267
268 for pattern in &patterns {
269 let path = config.vault_root.join(pattern);
270 if path.exists() {
271 return Ok(path);
272 }
273 }
274
275 let projects_dir = config.vault_root.join("Projects");
276 if !projects_dir.exists() {
277 return Err(DomainError::Other(format!(
278 "Project file not found for: {}",
279 project
280 )));
281 }
282
283 let archive_dir = config.vault_root.join("Projects/_archive");
285 let scan_dirs: Vec<&PathBuf> =
286 [&projects_dir, &archive_dir].into_iter().filter(|d| d.exists()).collect();
287
288 for dir in &scan_dirs {
291 if let Ok(entries) = fs::read_dir(dir) {
292 for entry in entries.flatten() {
293 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
294 let candidate = entry.path().join(format!("{}.md", project));
296 if candidate.exists() {
297 return Ok(candidate);
298 }
299 }
300 }
301 }
302 }
303
304 for dir in &scan_dirs {
308 if let Ok(entries) = fs::read_dir(dir) {
309 for entry in entries.flatten() {
310 let path = entry.path();
311 if path.is_dir() {
312 if let Ok(files) = fs::read_dir(&path) {
314 for file_entry in files.flatten() {
315 let file_path = file_entry.path();
316 if file_matches_project(&file_path, project) {
317 return Ok(file_path);
318 }
319 }
320 }
321 } else if file_matches_project(&path, project) {
322 return Ok(path);
323 }
324 }
325 }
326 }
327
328 Err(DomainError::Other(format!("Project file not found for: {}", project)))
329}
330
331fn file_matches_project(path: &Path, project: &str) -> bool {
333 if path.extension().map(|e| e == "md").unwrap_or(false)
334 && let Ok(content) = fs::read_to_string(path)
335 && let Ok(parsed) = crate::frontmatter::parse(&content)
336 && let Some(fm) = parsed.frontmatter
337 {
338 if let Some(pid) = fm.fields.get("project-id")
340 && pid.as_str() == Some(project)
341 {
342 return true;
343 }
344 if let Some(title) = fm.fields.get("title")
346 && title.as_str().map(|s| s.eq_ignore_ascii_case(project)).unwrap_or(false)
347 {
348 return true;
349 }
350 }
351 false
352}
353
354fn extract_project_slug(project_file: &Path, vault_root: &Path) -> String {
360 let rel = project_file.strip_prefix(vault_root).unwrap_or(project_file);
361 if let Some(parent) = rel.parent()
365 && let Some(dir_name) = parent.file_name()
366 {
367 let name = dir_name.to_string_lossy();
368 if !name.eq_ignore_ascii_case("projects") && name != "_archive" {
369 return name.to_string();
370 }
371 }
372 project_file.file_stem().unwrap_or_default().to_string_lossy().to_string()
373}
374
375fn increment_project_counter(config: &ResolvedConfig, project: &str) -> DomainResult<()> {
377 let project_file = find_project_file(config, project)?;
378
379 let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
380
381 let parsed = crate::frontmatter::parse(&content).map_err(|e| {
383 DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
384 })?;
385
386 let mut fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
387
388 let current = fields
389 .get("task_counter")
390 .and_then(|v| v.as_u64())
391 .map(|n| n as u32)
392 .unwrap_or(0);
393
394 fields.insert(
395 "task_counter".to_string(),
396 serde_yaml::Value::Number((current + 1).into()),
397 );
398
399 let yaml = serde_yaml::to_string(&fields).map_err(|e| {
401 DomainError::Other(format!("Failed to serialize frontmatter: {}", e))
402 })?;
403
404 let new_content = format!("---\n{}---\n{}", yaml, parsed.body);
405 fs::write(&project_file, new_content).map_err(DomainError::Io)?;
406
407 Ok(())
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::path::Path;
414
415 #[test]
416 fn test_file_matches_project_by_project_id() {
417 let dir = tempfile::tempdir().unwrap();
418 let path = dir.path().join("test-project.md");
419 fs::write(
420 &path,
421 "---\ntype: project\ntitle: Test Project\nproject-id: TST\ntask_counter: 0\n---\n",
422 )
423 .unwrap();
424
425 assert!(file_matches_project(&path, "TST"));
426 assert!(!file_matches_project(&path, "tst")); assert!(!file_matches_project(&path, "NOPE"));
428 }
429
430 #[test]
431 fn test_file_matches_project_by_title() {
432 let dir = tempfile::tempdir().unwrap();
433 let path = dir.path().join("test-project.md");
434 fs::write(
435 &path,
436 "---\ntype: project\ntitle: SEB Account\nproject-id: SAE\ntask_counter: 0\n---\n",
437 )
438 .unwrap();
439
440 assert!(file_matches_project(&path, "SEB Account"));
441 assert!(file_matches_project(&path, "seb account")); assert!(file_matches_project(&path, "SEB ACCOUNT")); assert!(!file_matches_project(&path, "Other Project"));
444 }
445
446 #[test]
447 fn test_file_matches_project_no_match() {
448 let dir = tempfile::tempdir().unwrap();
449 let path = dir.path().join("test-project.md");
450 fs::write(&path, "---\ntype: project\ntitle: My Project\nproject-id: MPR\n---\n")
451 .unwrap();
452
453 assert!(!file_matches_project(&path, "Other"));
454 assert!(!file_matches_project(&path, ""));
455 }
456
457 #[test]
458 fn test_file_matches_project_non_md_file() {
459 let dir = tempfile::tempdir().unwrap();
460 let path = dir.path().join("readme.txt");
461 fs::write(&path, "---\ntitle: Test\nproject-id: TST\n---\n").unwrap();
462
463 assert!(!file_matches_project(&path, "TST"));
464 }
465
466 #[test]
467 fn test_extract_project_slug_subfolder() {
468 let vault_root = Path::new("/vault");
469 let project_file = Path::new("/vault/Projects/seb-account/seb-account.md");
470 assert_eq!(extract_project_slug(project_file, vault_root), "seb-account");
471 }
472
473 #[test]
474 fn test_extract_project_slug_flat() {
475 let vault_root = Path::new("/vault");
476 let project_file = Path::new("/vault/Projects/seb-account.md");
477 assert_eq!(extract_project_slug(project_file, vault_root), "seb-account");
478 }
479
480 #[test]
481 fn test_extract_project_slug_nested_deeply() {
482 let vault_root = Path::new("/vault");
484 let project_file = Path::new("/vault/Projects/my-proj/my-proj.md");
485 assert_eq!(extract_project_slug(project_file, vault_root), "my-proj");
486 }
487
488 #[test]
489 fn test_find_project_file_by_title() {
490 let dir = tempfile::tempdir().unwrap();
491 let vault_root = dir.path();
492
493 let project_dir = vault_root.join("Projects/seb-account");
495 fs::create_dir_all(&project_dir).unwrap();
496 let project_file = project_dir.join("seb-account.md");
497 fs::write(
498 &project_file,
499 "---\ntype: project\ntitle: SEB Account\nproject-id: SAE\ntask_counter: 3\n---\n",
500 )
501 .unwrap();
502
503 let config = ResolvedConfig {
504 vault_root: vault_root.to_path_buf(),
505 ..make_test_config(vault_root)
506 };
507
508 let result = find_project_file(&config, "seb-account");
510 assert!(result.is_ok(), "Should resolve by slug");
511
512 let result = find_project_file(&config, "SEB Account");
514 assert!(result.is_ok(), "Should resolve by title");
515 assert_eq!(result.unwrap(), project_file);
516
517 let result = find_project_file(&config, "seb account");
519 assert!(result.is_ok(), "Should resolve by title case-insensitively");
520
521 let result = find_project_file(&config, "SAE");
523 assert!(result.is_ok(), "Should resolve by project-id");
524
525 let result = find_project_file(&config, "Unknown Project");
527 assert!(result.is_err(), "Should fail for unknown project");
528 }
529
530 #[test]
531 fn test_task_belongs_to_project_active_path() {
532 assert!(task_belongs_to_project(
533 "Projects/my-project/Tasks/TST-001.md",
534 "my-project"
535 ));
536 }
537
538 #[test]
539 fn test_task_belongs_to_project_archive_path() {
540 assert!(task_belongs_to_project(
541 "Projects/_archive/my-project/Tasks/TST-001.md",
542 "my-project"
543 ));
544 }
545
546 #[test]
547 fn test_task_belongs_to_project_wrong_project() {
548 assert!(!task_belongs_to_project(
549 "Projects/other-project/Tasks/TST-001.md",
550 "my-project"
551 ));
552 }
553
554 #[test]
555 fn test_task_belongs_to_project_inbox() {
556 assert!(!task_belongs_to_project("Inbox/INB-001.md", "my-project"));
557 }
558
559 #[test]
560 fn test_extract_project_slug_archive() {
561 let vault_root = Path::new("/vault");
562 let project_file =
563 Path::new("/vault/Projects/_archive/seb-account/seb-account.md");
564 assert_eq!(extract_project_slug(project_file, vault_root), "seb-account");
565 }
566
567 #[test]
568 fn test_find_project_file_in_archive() {
569 let dir = tempfile::tempdir().unwrap();
570 let vault_root = dir.path();
571
572 let archive_dir = vault_root.join("Projects/_archive/old-proj");
574 fs::create_dir_all(&archive_dir).unwrap();
575 let project_file = archive_dir.join("old-proj.md");
576 fs::write(
577 &project_file,
578 "---\ntype: project\ntitle: Old Project\nproject-id: OLD\ntask_counter: 5\nstatus: archived\n---\n",
579 )
580 .unwrap();
581
582 fs::create_dir_all(vault_root.join("Projects")).unwrap();
584
585 let config = make_test_config(vault_root);
586
587 let result = find_project_file(&config, "old-proj");
589 assert!(result.is_ok(), "Should resolve archived project by slug");
590 assert_eq!(result.unwrap(), project_file);
591
592 let result = find_project_file(&config, "OLD");
594 assert!(result.is_ok(), "Should resolve archived project by project-id");
595 }
596
597 fn make_test_config(vault_root: &Path) -> ResolvedConfig {
598 ResolvedConfig {
599 active_profile: "test".into(),
600 vault_root: vault_root.to_path_buf(),
601 templates_dir: vault_root.join(".mdvault/templates"),
602 captures_dir: vault_root.join(".mdvault/captures"),
603 macros_dir: vault_root.join(".mdvault/macros"),
604 typedefs_dir: vault_root.join(".mdvault/typedefs"),
605 excluded_folders: vec![],
606 security: Default::default(),
607 logging: Default::default(),
608 activity: Default::default(),
609 }
610 }
611}