Skip to main content

mdvault_core/domain/behaviors/
project.rs

1//! Project note type behavior.
2//!
3//! Projects have:
4//! - 3-letter ID generated from title
5//! - task_counter initialized to 0
6//! - Logging to daily note
7//! - Output path: Projects/{id}/{id}.md
8
9use 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
19/// Behavior implementation for project notes.
20pub struct ProjectBehavior {
21    typedef: Option<Arc<TypeDefinition>>,
22}
23
24impl ProjectBehavior {
25    /// Create a new ProjectBehavior, optionally wrapping a Lua typedef override.
26    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        // Check if already provided via vars
34        if let Some(id) = ctx.get_var("project-id") {
35            return Ok(Some(id.to_string()));
36        }
37
38        // Generate 3-letter ID from title
39        let computed = generate_project_id(&ctx.title);
40        Ok(Some(computed))
41    }
42
43    fn output_path(&self, ctx: &CreationContext) -> DomainResult<PathBuf> {
44        // Check Lua typedef for output template first
45        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        // Default: Projects/{id}/{id}.md
52        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        // Generate or use provided project ID
71        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        // Set core metadata
80        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        // Log to daily note
90        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                // Log warning but don't fail the creation
100                tracing::warn!("Failed to log to daily note: {}", e);
101            }
102        }
103
104        // TODO: Run Lua on_create hook if defined (requires VaultContext)
105
106        Ok(())
107    }
108}
109
110impl NotePrompts for ProjectBehavior {
111    fn type_prompts(&self, ctx: &PromptContext) -> Vec<FieldPrompt> {
112        let mut prompts = vec![];
113
114        // Project ID prompt with computed default
115        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
136// --- Helper functions ---
137
138/// Generate a 3-letter project ID from a title.
139///
140/// Takes the first letter of the first 3 words, uppercased.
141/// Examples:
142/// - "My Cool Project" -> "MCP"
143/// - "Test" -> "TES"
144/// - "A B" -> "AB"
145fn 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    // Pad with characters from the first word if needed
157    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    // Ensure at least 3 characters
169    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"); // A + B + X (padding)
185        assert_eq!(generate_project_id("Hello World"), "HWE"); // H + W + E (from Hello)
186        assert_eq!(generate_project_id("One Two Three Four"), "OTT");
187    }
188}