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            // Get project counter and canonical slug
85            let (project_id, counter, slug) = get_project_info(ctx.config, &project)?;
86            (format!("{}-{:03}", project_id, counter + 1), slug)
87        };
88
89        // Set core metadata
90        ctx.core_metadata.task_id = Some(task_id.clone());
91        ctx.core_metadata.project =
92            if project == "inbox" { None } else { Some(project.clone()) };
93        ctx.set_var("task-id", &task_id);
94        if project != "inbox" {
95            ctx.set_var("project", &project);
96        }
97
98        Ok(())
99    }
100
101    fn after_create(&self, ctx: &CreationContext, _content: &str) -> DomainResult<()> {
102        let project = ctx.get_var("project").unwrap_or("inbox");
103
104        // Increment project counter if not inbox
105        if project != "inbox" {
106            increment_project_counter(ctx.config, project)?;
107        }
108
109        // Log to daily note
110        if let Some(ref output_path) = ctx.output_path {
111            let task_id = ctx.core_metadata.task_id.as_deref().unwrap_or("");
112            if let Err(e) = super::super::services::DailyLogService::log_creation(
113                ctx.config,
114                "task",
115                &ctx.title,
116                task_id,
117                output_path,
118            ) {
119                // Log warning but don't fail the creation
120                tracing::warn!("Failed to log to daily note: {}", e);
121            }
122        }
123
124        // Log to project note
125        if project != "inbox"
126            && let Ok(project_file) = find_project_file(ctx.config, project)
127        {
128            let task_id = ctx.core_metadata.task_id.as_deref().unwrap_or("");
129            let message = format!("Created task [[{}]]: {}", task_id, ctx.title);
130            if let Err(e) = super::super::services::ProjectLogService::log_entry(
131                &project_file,
132                &message,
133            ) {
134                tracing::warn!("Failed to log to project note: {}", e);
135            }
136        }
137
138        // TODO: Run Lua on_create hook if defined (requires VaultContext)
139
140        Ok(())
141    }
142}
143
144impl NotePrompts for TaskBehavior {
145    fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
146        let mut prompts = vec![];
147
148        // Project selector (if not provided)
149        if !ctx.provided_vars.contains_key("project") && !ctx.batch_mode {
150            prompts.push(FieldPrompt {
151                field_name: "project".into(),
152                prompt_text: "Select project for this task".into(),
153                prompt_type: PromptType::ProjectSelector,
154                required: false, // Can be inbox
155                default_value: Some("inbox".into()),
156            });
157        }
158
159        prompts
160    }
161}
162
163impl NoteBehavior for TaskBehavior {
164    fn type_name(&self) -> &'static str {
165        "task"
166    }
167}
168
169// --- Helper functions (to be moved/refactored) ---
170
171use crate::config::types::ResolvedConfig;
172use std::fs;
173
174/// Generate an inbox task ID by scanning the Inbox directory.
175fn generate_inbox_task_id(vault_root: &std::path::Path) -> DomainResult<String> {
176    let inbox_dir = vault_root.join("Inbox");
177
178    let mut max_num = 0u32;
179
180    if inbox_dir.exists() {
181        for entry in fs::read_dir(&inbox_dir).map_err(DomainError::Io)? {
182            let entry = entry.map_err(DomainError::Io)?;
183            let name = entry.file_name();
184            let name_str = name.to_string_lossy();
185
186            // Parse INB-XXX.md pattern
187            if let Some(stem) = name_str.strip_suffix(".md")
188                && let Some(num_str) = stem.strip_prefix("INB-")
189                && let Ok(num) = num_str.parse::<u32>()
190            {
191                max_num = max_num.max(num);
192            }
193        }
194    }
195
196    Ok(format!("INB-{:03}", max_num + 1))
197}
198
199/// Get project info (project-id, task_counter, canonical slug) from project file.
200fn get_project_info(
201    config: &ResolvedConfig,
202    project: &str,
203) -> DomainResult<(String, u32, String)> {
204    let project_file = find_project_file(config, project)?;
205    let slug = extract_project_slug(&project_file, &config.vault_root);
206
207    let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
208
209    // Parse frontmatter
210    let parsed = crate::frontmatter::parse(&content).map_err(|e| {
211        DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
212    })?;
213
214    let fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
215
216    let project_id = fields
217        .get("project-id")
218        .and_then(|v| v.as_str())
219        .map(|s| s.to_string())
220        .unwrap_or_else(|| project.to_uppercase());
221
222    let counter = fields
223        .get("task_counter")
224        .and_then(|v| v.as_u64())
225        .map(|n| n as u32)
226        .unwrap_or(0);
227
228    Ok((project_id, counter, slug))
229}
230
231/// Find the project file by project name/ID/title.
232///
233/// Searches in the following order:
234/// 1. Direct path patterns (fast path)
235/// 2. File named {project}.md in any Projects subfolder
236/// 3. Any project file with matching project-id or title in frontmatter
237pub fn find_project_file(
238    config: &ResolvedConfig,
239    project: &str,
240) -> DomainResult<PathBuf> {
241    // Try common patterns first (fast path)
242    let patterns = [
243        format!("Projects/{}/{}.md", project, project),
244        format!("Projects/{}.md", project),
245        format!("projects/{}/{}.md", project.to_lowercase(), project.to_lowercase()),
246    ];
247
248    for pattern in &patterns {
249        let path = config.vault_root.join(pattern);
250        if path.exists() {
251            return Ok(path);
252        }
253    }
254
255    let projects_dir = config.vault_root.join("Projects");
256    if !projects_dir.exists() {
257        return Err(DomainError::Other(format!(
258            "Project file not found for: {}",
259            project
260        )));
261    }
262
263    // Search for project file by name in any Projects subfolder
264    // Handles structures like: Projects/my-project-folder/MDV.md
265    if let Ok(entries) = fs::read_dir(&projects_dir) {
266        for entry in entries.flatten() {
267            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
268                // Look for {project}.md in this folder
269                let candidate = entry.path().join(format!("{}.md", project));
270                if candidate.exists() {
271                    return Ok(candidate);
272                }
273            }
274        }
275    }
276
277    // Search by frontmatter project-id or title
278    // Handles structures where file is named differently (e.g., markdownvault-development.md with project-id: MDV)
279    // Also resolves by human-readable title (e.g., "SEB Account" matches title field)
280    if let Ok(entries) = fs::read_dir(&projects_dir) {
281        for entry in entries.flatten() {
282            let path = entry.path();
283            if path.is_dir() {
284                // Look for any .md file in this folder
285                if let Ok(files) = fs::read_dir(&path) {
286                    for file_entry in files.flatten() {
287                        let file_path = file_entry.path();
288                        if file_matches_project(&file_path, project) {
289                            return Ok(file_path);
290                        }
291                    }
292                }
293            } else if file_matches_project(&path, project) {
294                return Ok(path);
295            }
296        }
297    }
298
299    Err(DomainError::Other(format!("Project file not found for: {}", project)))
300}
301
302/// Check if a file matches a project by project-id (exact) or title (case-insensitive).
303fn file_matches_project(path: &Path, project: &str) -> bool {
304    if path.extension().map(|e| e == "md").unwrap_or(false)
305        && let Ok(content) = fs::read_to_string(path)
306        && let Ok(parsed) = crate::frontmatter::parse(&content)
307        && let Some(fm) = parsed.frontmatter
308    {
309        // Check project-id (exact match)
310        if let Some(pid) = fm.fields.get("project-id")
311            && pid.as_str() == Some(project)
312        {
313            return true;
314        }
315        // Check title (case-insensitive)
316        if let Some(title) = fm.fields.get("title")
317            && title.as_str().map(|s| s.eq_ignore_ascii_case(project)).unwrap_or(false)
318        {
319            return true;
320        }
321    }
322    false
323}
324
325/// Extract the canonical project directory slug from a resolved project file path.
326///
327/// Given `Projects/seb-account/seb-account.md`, returns `"seb-account"`.
328/// Given `Projects/seb-account.md`, returns `"seb-account"`.
329fn extract_project_slug(project_file: &Path, vault_root: &Path) -> String {
330    let rel = project_file.strip_prefix(vault_root).unwrap_or(project_file);
331    // Projects/seb-account/seb-account.md → parent dir name = "seb-account"
332    // Projects/seb-account.md → file stem = "seb-account"
333    if let Some(parent) = rel.parent()
334        && let Some(dir_name) = parent.file_name()
335    {
336        let name = dir_name.to_string_lossy();
337        if !name.eq_ignore_ascii_case("projects") {
338            return name.to_string();
339        }
340    }
341    project_file.file_stem().unwrap_or_default().to_string_lossy().to_string()
342}
343
344/// Increment the task_counter in a project file.
345fn increment_project_counter(config: &ResolvedConfig, project: &str) -> DomainResult<()> {
346    let project_file = find_project_file(config, project)?;
347
348    let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
349
350    // Parse frontmatter
351    let parsed = crate::frontmatter::parse(&content).map_err(|e| {
352        DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
353    })?;
354
355    let mut fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
356
357    let current = fields
358        .get("task_counter")
359        .and_then(|v| v.as_u64())
360        .map(|n| n as u32)
361        .unwrap_or(0);
362
363    fields.insert(
364        "task_counter".to_string(),
365        serde_yaml::Value::Number((current + 1).into()),
366    );
367
368    // Rebuild content with updated frontmatter
369    let yaml = serde_yaml::to_string(&fields).map_err(|e| {
370        DomainError::Other(format!("Failed to serialize frontmatter: {}", e))
371    })?;
372
373    let new_content = format!("---\n{}---\n{}", yaml, parsed.body);
374    fs::write(&project_file, new_content).map_err(DomainError::Io)?;
375
376    Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::path::Path;
383
384    #[test]
385    fn test_file_matches_project_by_project_id() {
386        let dir = tempfile::tempdir().unwrap();
387        let path = dir.path().join("test-project.md");
388        fs::write(
389            &path,
390            "---\ntype: project\ntitle: Test Project\nproject-id: TST\ntask_counter: 0\n---\n",
391        )
392        .unwrap();
393
394        assert!(file_matches_project(&path, "TST"));
395        assert!(!file_matches_project(&path, "tst")); // project-id is exact match
396        assert!(!file_matches_project(&path, "NOPE"));
397    }
398
399    #[test]
400    fn test_file_matches_project_by_title() {
401        let dir = tempfile::tempdir().unwrap();
402        let path = dir.path().join("test-project.md");
403        fs::write(
404            &path,
405            "---\ntype: project\ntitle: SEB Account\nproject-id: SAE\ntask_counter: 0\n---\n",
406        )
407        .unwrap();
408
409        assert!(file_matches_project(&path, "SEB Account"));
410        assert!(file_matches_project(&path, "seb account")); // case-insensitive
411        assert!(file_matches_project(&path, "SEB ACCOUNT")); // case-insensitive
412        assert!(!file_matches_project(&path, "Other Project"));
413    }
414
415    #[test]
416    fn test_file_matches_project_no_match() {
417        let dir = tempfile::tempdir().unwrap();
418        let path = dir.path().join("test-project.md");
419        fs::write(&path, "---\ntype: project\ntitle: My Project\nproject-id: MPR\n---\n")
420            .unwrap();
421
422        assert!(!file_matches_project(&path, "Other"));
423        assert!(!file_matches_project(&path, ""));
424    }
425
426    #[test]
427    fn test_file_matches_project_non_md_file() {
428        let dir = tempfile::tempdir().unwrap();
429        let path = dir.path().join("readme.txt");
430        fs::write(&path, "---\ntitle: Test\nproject-id: TST\n---\n").unwrap();
431
432        assert!(!file_matches_project(&path, "TST"));
433    }
434
435    #[test]
436    fn test_extract_project_slug_subfolder() {
437        let vault_root = Path::new("/vault");
438        let project_file = Path::new("/vault/Projects/seb-account/seb-account.md");
439        assert_eq!(extract_project_slug(project_file, vault_root), "seb-account");
440    }
441
442    #[test]
443    fn test_extract_project_slug_flat() {
444        let vault_root = Path::new("/vault");
445        let project_file = Path::new("/vault/Projects/seb-account.md");
446        assert_eq!(extract_project_slug(project_file, vault_root), "seb-account");
447    }
448
449    #[test]
450    fn test_extract_project_slug_nested_deeply() {
451        // Edge case: Tasks subfolder shouldn't happen for project files, but test robustness
452        let vault_root = Path::new("/vault");
453        let project_file = Path::new("/vault/Projects/my-proj/my-proj.md");
454        assert_eq!(extract_project_slug(project_file, vault_root), "my-proj");
455    }
456
457    #[test]
458    fn test_find_project_file_by_title() {
459        let dir = tempfile::tempdir().unwrap();
460        let vault_root = dir.path();
461
462        // Create project structure: Projects/seb-account/seb-account.md
463        let project_dir = vault_root.join("Projects/seb-account");
464        fs::create_dir_all(&project_dir).unwrap();
465        let project_file = project_dir.join("seb-account.md");
466        fs::write(
467            &project_file,
468            "---\ntype: project\ntitle: SEB Account\nproject-id: SAE\ntask_counter: 3\n---\n",
469        )
470        .unwrap();
471
472        let config = ResolvedConfig {
473            vault_root: vault_root.to_path_buf(),
474            ..make_test_config(vault_root)
475        };
476
477        // Should resolve by slug (fast path)
478        let result = find_project_file(&config, "seb-account");
479        assert!(result.is_ok(), "Should resolve by slug");
480
481        // Should resolve by title
482        let result = find_project_file(&config, "SEB Account");
483        assert!(result.is_ok(), "Should resolve by title");
484        assert_eq!(result.unwrap(), project_file);
485
486        // Should resolve by title case-insensitively
487        let result = find_project_file(&config, "seb account");
488        assert!(result.is_ok(), "Should resolve by title case-insensitively");
489
490        // Should resolve by project-id
491        let result = find_project_file(&config, "SAE");
492        assert!(result.is_ok(), "Should resolve by project-id");
493
494        // Should fail for unknown
495        let result = find_project_file(&config, "Unknown Project");
496        assert!(result.is_err(), "Should fail for unknown project");
497    }
498
499    fn make_test_config(vault_root: &Path) -> ResolvedConfig {
500        ResolvedConfig {
501            active_profile: "test".into(),
502            vault_root: vault_root.to_path_buf(),
503            templates_dir: vault_root.join(".mdvault/templates"),
504            captures_dir: vault_root.join(".mdvault/captures"),
505            macros_dir: vault_root.join(".mdvault/macros"),
506            typedefs_dir: vault_root.join(".mdvault/typedefs"),
507            excluded_folders: vec![],
508            security: Default::default(),
509            logging: Default::default(),
510            activity: Default::default(),
511        }
512    }
513}