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 = if project == "inbox" {
82            generate_inbox_task_id(&ctx.config.vault_root)?
83        } else {
84            // Get project counter and generate ID
85            let (project_id, counter) = get_project_info(ctx.config, &project)?;
86            format!("{}-{:03}", project_id, counter + 1)
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        // TODO: Run Lua on_create hook if defined (requires VaultContext)
125
126        Ok(())
127    }
128}
129
130impl NotePrompts for TaskBehavior {
131    fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
132        let mut prompts = vec![];
133
134        // Project selector (if not provided)
135        if !ctx.provided_vars.contains_key("project") && !ctx.batch_mode {
136            prompts.push(FieldPrompt {
137                field_name: "project".into(),
138                prompt_text: "Select project for this task".into(),
139                prompt_type: PromptType::ProjectSelector,
140                required: false, // Can be inbox
141                default_value: Some("inbox".into()),
142            });
143        }
144
145        prompts
146    }
147}
148
149impl NoteBehavior for TaskBehavior {
150    fn type_name(&self) -> &'static str {
151        "task"
152    }
153}
154
155// --- Helper functions (to be moved/refactored) ---
156
157use crate::config::types::ResolvedConfig;
158use std::fs;
159
160/// Generate an inbox task ID by scanning the Inbox directory.
161fn generate_inbox_task_id(vault_root: &std::path::Path) -> DomainResult<String> {
162    let inbox_dir = vault_root.join("Inbox");
163
164    let mut max_num = 0u32;
165
166    if inbox_dir.exists() {
167        for entry in fs::read_dir(&inbox_dir).map_err(DomainError::Io)? {
168            let entry = entry.map_err(DomainError::Io)?;
169            let name = entry.file_name();
170            let name_str = name.to_string_lossy();
171
172            // Parse INB-XXX.md pattern
173            if let Some(stem) = name_str.strip_suffix(".md")
174                && let Some(num_str) = stem.strip_prefix("INB-")
175                && let Ok(num) = num_str.parse::<u32>()
176            {
177                max_num = max_num.max(num);
178            }
179        }
180    }
181
182    Ok(format!("INB-{:03}", max_num + 1))
183}
184
185/// Get project info (project-id and task_counter) from project file.
186fn get_project_info(
187    config: &ResolvedConfig,
188    project: &str,
189) -> DomainResult<(String, u32)> {
190    let project_file = find_project_file(config, project)?;
191
192    let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
193
194    // Parse frontmatter
195    let parsed = crate::frontmatter::parse(&content).map_err(|e| {
196        DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
197    })?;
198
199    let fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
200
201    let project_id = fields
202        .get("project-id")
203        .and_then(|v| v.as_str())
204        .map(|s| s.to_string())
205        .unwrap_or_else(|| project.to_uppercase());
206
207    let counter = fields
208        .get("task_counter")
209        .and_then(|v| v.as_u64())
210        .map(|n| n as u32)
211        .unwrap_or(0);
212
213    Ok((project_id, counter))
214}
215
216/// Find the project file by project name/ID.
217///
218/// Searches in the following order:
219/// 1. Direct path patterns (fast path)
220/// 2. File named {project}.md in any Projects subfolder
221/// 3. Any project file with matching project-id in frontmatter
222fn find_project_file(config: &ResolvedConfig, project: &str) -> DomainResult<PathBuf> {
223    // Try common patterns first (fast path)
224    let patterns = [
225        format!("Projects/{}/{}.md", project, project),
226        format!("Projects/{}.md", project),
227        format!("projects/{}/{}.md", project.to_lowercase(), project.to_lowercase()),
228    ];
229
230    for pattern in &patterns {
231        let path = config.vault_root.join(pattern);
232        if path.exists() {
233            return Ok(path);
234        }
235    }
236
237    let projects_dir = config.vault_root.join("Projects");
238    if !projects_dir.exists() {
239        return Err(DomainError::Other(format!(
240            "Project file not found for: {}",
241            project
242        )));
243    }
244
245    // Search for project file by name in any Projects subfolder
246    // Handles structures like: Projects/my-project-folder/MDV.md
247    if let Ok(entries) = fs::read_dir(&projects_dir) {
248        for entry in entries.flatten() {
249            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
250                // Look for {project}.md in this folder
251                let candidate = entry.path().join(format!("{}.md", project));
252                if candidate.exists() {
253                    return Ok(candidate);
254                }
255            }
256        }
257    }
258
259    // Search by frontmatter project-id
260    // Handles structures where file is named differently (e.g., markdownvault-development.md with project-id: MDV)
261    if let Ok(entries) = fs::read_dir(&projects_dir) {
262        for entry in entries.flatten() {
263            let path = entry.path();
264            if path.is_dir() {
265                // Look for any .md file in this folder
266                if let Ok(files) = fs::read_dir(&path) {
267                    for file_entry in files.flatten() {
268                        let file_path = file_entry.path();
269                        if file_has_project_id(&file_path, project) {
270                            return Ok(file_path);
271                        }
272                    }
273                }
274            } else if file_has_project_id(&path, project) {
275                return Ok(path);
276            }
277        }
278    }
279
280    Err(DomainError::Other(format!("Project file not found for: {}", project)))
281}
282
283/// Check if a file has a matching project-id in its frontmatter.
284fn file_has_project_id(path: &Path, project_id: &str) -> bool {
285    if path.extension().map(|e| e == "md").unwrap_or(false)
286        && let Ok(content) = fs::read_to_string(path)
287        && let Ok(parsed) = crate::frontmatter::parse(&content)
288        && let Some(fm) = parsed.frontmatter
289        && let Some(pid) = fm.fields.get("project-id")
290    {
291        return pid.as_str() == Some(project_id);
292    }
293    false
294}
295
296/// Increment the task_counter in a project file.
297fn increment_project_counter(config: &ResolvedConfig, project: &str) -> DomainResult<()> {
298    let project_file = find_project_file(config, project)?;
299
300    let content = fs::read_to_string(&project_file).map_err(DomainError::Io)?;
301
302    // Parse frontmatter
303    let parsed = crate::frontmatter::parse(&content).map_err(|e| {
304        DomainError::Other(format!("Failed to parse project frontmatter: {}", e))
305    })?;
306
307    let mut fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
308
309    let current = fields
310        .get("task_counter")
311        .and_then(|v| v.as_u64())
312        .map(|n| n as u32)
313        .unwrap_or(0);
314
315    fields.insert(
316        "task_counter".to_string(),
317        serde_yaml::Value::Number((current + 1).into()),
318    );
319
320    // Rebuild content with updated frontmatter
321    let yaml = serde_yaml::to_string(&fields).map_err(|e| {
322        DomainError::Other(format!("Failed to serialize frontmatter: {}", e))
323    })?;
324
325    let new_content = format!("---\n{}---\n{}", yaml, parsed.body);
326    fs::write(&project_file, new_content).map_err(DomainError::Io)?;
327
328    Ok(())
329}