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 let (project_id, counter, slug) = get_project_info(ctx.config, &project)?;
86 (format!("{}-{:03}", project_id, counter + 1), slug)
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 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 Ok(())
141 }
142}
143
144impl NotePrompts for TaskBehavior {
145 fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
146 let mut prompts = vec![];
147
148 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, 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
169use crate::config::types::ResolvedConfig;
172use std::fs;
173
174fn 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 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
199fn 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 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
231pub fn find_project_file(
238 config: &ResolvedConfig,
239 project: &str,
240) -> DomainResult<PathBuf> {
241 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 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 let candidate = entry.path().join(format!("{}.md", project));
270 if candidate.exists() {
271 return Ok(candidate);
272 }
273 }
274 }
275 }
276
277 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 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
302fn 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 if let Some(pid) = fm.fields.get("project-id")
311 && pid.as_str() == Some(project)
312 {
313 return true;
314 }
315 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
325fn extract_project_slug(project_file: &Path, vault_root: &Path) -> String {
330 let rel = project_file.strip_prefix(vault_root).unwrap_or(project_file);
331 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
344fn 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 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 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")); 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")); assert!(file_matches_project(&path, "SEB ACCOUNT")); 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 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 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 let result = find_project_file(&config, "seb-account");
479 assert!(result.is_ok(), "Should resolve by slug");
480
481 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 let result = find_project_file(&config, "seb account");
488 assert!(result.is_ok(), "Should resolve by title case-insensitively");
489
490 let result = find_project_file(&config, "SAE");
492 assert!(result.is_ok(), "Should resolve by project-id");
493
494 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}