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 = if project == "inbox" {
82 generate_inbox_task_id(&ctx.config.vault_root)?
83 } else {
84 let (project_id, counter) = get_project_info(ctx.config, &project)?;
86 format!("{}-{:03}", project_id, counter + 1)
87 };
88
89 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 if project != "inbox" {
106 increment_project_counter(ctx.config, project)?;
107 }
108
109 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 tracing::warn!("Failed to log to daily note: {}", e);
121 }
122 }
123
124 Ok(())
127 }
128}
129
130impl NotePrompts for TaskBehavior {
131 fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
132 let mut prompts = vec![];
133
134 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, 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
155use crate::config::types::ResolvedConfig;
158use std::fs;
159
160fn 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 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
185fn 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 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
216fn find_project_file(config: &ResolvedConfig, project: &str) -> DomainResult<PathBuf> {
223 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 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 let candidate = entry.path().join(format!("{}.md", project));
252 if candidate.exists() {
253 return Ok(candidate);
254 }
255 }
256 }
257 }
258
259 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 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
283fn 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
296fn 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 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 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}