Skip to main content

mdvault_core/domain/behaviors/
task.rs

1//! Task note type behavior.
2//!
3//! Tasks have:
4//! - ID generated from project counter (TST-001) or inbox (INB-001)
5//! - Project selector prompt
6//! - Logging to daily note
7//! - Output path: Projects/{project}/Tasks/{id}.md or Inbox/{id}.md
8
9use 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
19/// Behavior implementation for task notes.
20pub struct TaskBehavior {
21    typedef: Option<Arc<TypeDefinition>>,
22}
23
24impl TaskBehavior {
25    /// Create a new TaskBehavior, optionally wrapping a Lua typedef override.
26    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        // ID generation is handled in before_create after project is known
34        // Return existing ID if already set
35        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        // Check Lua typedef for output template first
43        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        // Default path
50        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        // Generate task ID based on project
81        let (task_id, project) = if project == "inbox" {
82            (generate_inbox_task_id(&ctx.config.vault_root)?, project)
83        } else {
84            // Check if project is archived before allowing task creation
85            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            // Get project counter and canonical slug
98            let (project_id, counter, slug) = get_project_info(ctx.config, &project)?;
99            (format!("{}-{:03}", project_id, counter + 1), slug)
100        };
101
102        // Set core metadata
103        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        // Increment project counter if not inbox
118        if project != "inbox" {
119            increment_project_counter(ctx.config, project)?;
120        }
121
122        // Log to daily note
123        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                // Log warning but don't fail the creation
133                tracing::warn!("Failed to log to daily note: {}", e);
134            }
135        }
136
137        // Log to project note
138        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        // TODO: Run Lua on_create hook if defined (requires VaultContext)
152
153        Ok(())
154    }
155}
156
157impl NotePrompts for TaskBehavior {
158    fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
159        let mut prompts = vec![];
160
161        // Project selector (if not provided)
162        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, // Can be inbox
168                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
182// --- Helper functions (to be moved/refactored) ---
183
184use crate::config::types::ResolvedConfig;
185use std::fs;
186
187/// Generate an inbox task ID by scanning the Inbox directory.
188fn 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            // Parse INB-XXX.md pattern
200            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
212/// Get project info (project-id, task_counter, canonical slug) from project file.
213fn 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    // Parse frontmatter
223    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
244/// Check if a task path belongs to a project (active or archived).
245pub 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
250/// Find the project file by project name/ID/title.
251///
252/// Searches in the following order:
253/// 1. Direct path patterns (fast path), including archive
254/// 2. File named {project}.md in any Projects subfolder
255/// 3. Any project file with matching project-id or title in frontmatter
256pub fn find_project_file(
257    config: &ResolvedConfig,
258    project: &str,
259) -> DomainResult<PathBuf> {
260    // Try common patterns first (fast path)
261    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    // Directories to scan: Projects/ and Projects/_archive/
284    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    // Search for project file by name in any subfolder
289    // Handles structures like: Projects/my-project-folder/MDV.md
290    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                    // Look for {project}.md in this folder
295                    let candidate = entry.path().join(format!("{}.md", project));
296                    if candidate.exists() {
297                        return Ok(candidate);
298                    }
299                }
300            }
301        }
302    }
303
304    // Search by frontmatter project-id or title
305    // Handles structures where file is named differently (e.g., markdownvault-development.md with project-id: MDV)
306    // Also resolves by human-readable title (e.g., "SEB Account" matches title field)
307    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                    // Look for any .md file in this folder
313                    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
331/// Check if a file matches a project by project-id (exact) or title (case-insensitive).
332fn 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        // Check project-id (exact match)
339        if let Some(pid) = fm.fields.get("project-id")
340            && pid.as_str() == Some(project)
341        {
342            return true;
343        }
344        // Check title (case-insensitive)
345        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
354/// Extract the canonical project directory slug from a resolved project file path.
355///
356/// Given `Projects/seb-account/seb-account.md`, returns `"seb-account"`.
357/// Given `Projects/_archive/seb-account/seb-account.md`, returns `"seb-account"`.
358/// Given `Projects/seb-account.md`, returns `"seb-account"`.
359fn extract_project_slug(project_file: &Path, vault_root: &Path) -> String {
360    let rel = project_file.strip_prefix(vault_root).unwrap_or(project_file);
361    // Projects/seb-account/seb-account.md → parent dir name = "seb-account"
362    // Projects/_archive/seb-account/seb-account.md → parent dir name = "seb-account"
363    // Projects/seb-account.md → file stem = "seb-account"
364    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
375/// Increment the task_counter in a project file.
376fn 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    // Parse frontmatter
382    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    // Rebuild content with updated frontmatter
400    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")); // project-id is exact match
427        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")); // case-insensitive
442        assert!(file_matches_project(&path, "SEB ACCOUNT")); // case-insensitive
443        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        // Edge case: Tasks subfolder shouldn't happen for project files, but test robustness
483        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        // Create project structure: Projects/seb-account/seb-account.md
494        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        // Should resolve by slug (fast path)
509        let result = find_project_file(&config, "seb-account");
510        assert!(result.is_ok(), "Should resolve by slug");
511
512        // Should resolve by title
513        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        // Should resolve by title case-insensitively
518        let result = find_project_file(&config, "seb account");
519        assert!(result.is_ok(), "Should resolve by title case-insensitively");
520
521        // Should resolve by project-id
522        let result = find_project_file(&config, "SAE");
523        assert!(result.is_ok(), "Should resolve by project-id");
524
525        // Should fail for unknown
526        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        // Create archived project structure
573        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        // Also create Projects/ dir (required for the function)
583        fs::create_dir_all(vault_root.join("Projects")).unwrap();
584
585        let config = make_test_config(vault_root);
586
587        // Should resolve by slug (fast path for archive)
588        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        // Should resolve by project-id
593        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            typedefs_fallback_dir: None,
606            excluded_folders: vec![],
607            security: Default::default(),
608            logging: Default::default(),
609            activity: Default::default(),
610        }
611    }
612}