mdvault_core/domain/behaviors/
project.rs1use std::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 ProjectBehavior {
21 typedef: Option<Arc<TypeDefinition>>,
22}
23
24impl ProjectBehavior {
25 pub fn new(typedef: Option<Arc<TypeDefinition>>) -> Self {
27 Self { typedef }
28 }
29}
30
31impl NoteIdentity for ProjectBehavior {
32 fn generate_id(&self, ctx: &CreationContext) -> DomainResult<Option<String>> {
33 if let Some(id) = ctx.get_var("project-id") {
35 return Ok(Some(id.to_string()));
36 }
37
38 let computed = generate_project_id(&ctx.title);
40 Ok(Some(computed))
41 }
42
43 fn output_path(&self, ctx: &CreationContext) -> DomainResult<PathBuf> {
44 if let Some(ref td) = self.typedef
46 && let Some(ref output) = td.output
47 {
48 return super::render_output_template(output, ctx);
49 }
50
51 let project_id =
53 ctx.core_metadata.project_id.as_ref().ok_or_else(|| {
54 DomainError::PathResolution("project-id not set".into())
55 })?;
56
57 Ok(ctx
58 .config
59 .vault_root
60 .join(format!("Projects/{}/{}.md", project_id, project_id)))
61 }
62
63 fn core_fields(&self) -> Vec<&'static str> {
64 vec!["type", "title", "project-id", "task_counter"]
65 }
66}
67
68impl NoteLifecycle for ProjectBehavior {
69 fn before_create(&self, ctx: &mut CreationContext) -> DomainResult<()> {
70 let project_id = ctx
72 .get_var("project-id")
73 .map(|s| s.to_string())
74 .or_else(|| self.generate_id(ctx).ok().flatten())
75 .ok_or_else(|| {
76 DomainError::IdGeneration("could not generate project-id".into())
77 })?;
78
79 ctx.core_metadata.project_id = Some(project_id.clone());
81 ctx.core_metadata.task_counter = Some(0);
82 ctx.set_var("project-id", &project_id);
83 ctx.set_var("task_counter", "0");
84
85 Ok(())
86 }
87
88 fn after_create(&self, ctx: &CreationContext, _content: &str) -> DomainResult<()> {
89 if let Some(ref output_path) = ctx.output_path {
91 let project_id = ctx.core_metadata.project_id.as_deref().unwrap_or("");
92 if let Err(e) = super::super::services::DailyLogService::log_creation(
93 ctx.config,
94 "project",
95 &ctx.title,
96 project_id,
97 output_path,
98 ) {
99 tracing::warn!("Failed to log to daily note: {}", e);
101 }
102 }
103
104 Ok(())
107 }
108}
109
110impl NotePrompts for ProjectBehavior {
111 fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
112 let mut prompts = vec![];
113
114 if !ctx.provided_vars.contains_key("project-id") && !ctx.batch_mode {
116 let computed = generate_project_id(ctx.title);
117 prompts.push(FieldPrompt {
118 field_name: "project-id".into(),
119 prompt_text: "Project ID (3-letter code)".into(),
120 prompt_type: PromptType::Text,
121 required: true,
122 default_value: Some(computed),
123 });
124 }
125
126 prompts
127 }
128}
129
130impl NoteBehavior for ProjectBehavior {
131 fn type_name(&self) -> &'static str {
132 "project"
133 }
134}
135
136fn generate_project_id(title: &str) -> String {
146 let words: Vec<&str> = title.split_whitespace().collect();
147
148 let mut id = String::with_capacity(3);
149
150 for word in words.iter().take(3) {
151 if let Some(c) = word.chars().next() {
152 id.push(c.to_ascii_uppercase());
153 }
154 }
155
156 if id.len() < 3
158 && let Some(first_word) = words.first()
159 {
160 for c in first_word.chars().skip(1) {
161 if id.len() >= 3 {
162 break;
163 }
164 id.push(c.to_ascii_uppercase());
165 }
166 }
167
168 while id.len() < 3 {
170 id.push('X');
171 }
172
173 id
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_generate_project_id() {
182 assert_eq!(generate_project_id("My Cool Project"), "MCP");
183 assert_eq!(generate_project_id("Test"), "TES");
184 assert_eq!(generate_project_id("A B"), "ABX"); assert_eq!(generate_project_id("Hello World"), "HWE"); assert_eq!(generate_project_id("One Two Three Four"), "OTT");
187 }
188}