diff --git a/packages/core/src/behaviors/mod.rs b/packages/core/src/behaviors/mod.rs index 89a6fb6a9..61ae8837b 100644 --- a/packages/core/src/behaviors/mod.rs +++ b/packages/core/src/behaviors/mod.rs @@ -1051,6 +1051,35 @@ impl SchemaNodeBehavior { validate_schema_field(field)?; } + // Validate title_template syntax: every '{' must have a matching '}' and a non-empty field name + if let Some(template) = &schema.title_template { + let bytes = template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + // Find matching '}' + match bytes[i + 1..].iter().position(|&c| c == b'}') { + None => { + return Err(NodeValidationError::InvalidProperties( + "title_template contains an unclosed '{' placeholder".to_string(), + )); + } + Some(end) => { + let field_name = &template[i + 1..i + 1 + end]; + if field_name.is_empty() { + return Err(NodeValidationError::InvalidProperties( + "title_template contains an empty '{}' placeholder".to_string(), + )); + } + i += 1 + end + 1; // skip past '}' + continue; + } + } + } + i += 1; + } + } + Ok(()) } } @@ -2856,6 +2885,112 @@ mod tests { )); } + // ========================================================================= + // title_template Validation Tests (Issue #824) + // ========================================================================= + + #[test] + fn test_title_template_valid() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + let node = Node::new( + "schema".to_string(), + "Customer".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "", + "titleTemplate": "{first_name} {last_name}", + "fields": [], + "relationships": [] + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + assert!( + behavior.validate_schema_node(&schema).is_ok(), + "Valid title_template should pass validation" + ); + } + + #[test] + fn test_title_template_unclosed_brace_rejected() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + let node = Node::new( + "schema".to_string(), + "Customer".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "", + "titleTemplate": "{first_name", + "fields": [], + "relationships": [] + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + let result = behavior.validate_schema_node(&schema); + assert!(result.is_err(), "Unclosed brace should fail validation"); + assert!(matches!( + result, + Err(NodeValidationError::InvalidProperties(ref msg)) + if msg.contains("unclosed") + )); + } + + #[test] + fn test_title_template_empty_placeholder_rejected() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + let node = Node::new( + "schema".to_string(), + "Customer".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "", + "titleTemplate": "{} {last_name}", + "fields": [], + "relationships": [] + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + let result = behavior.validate_schema_node(&schema); + assert!(result.is_err(), "Empty placeholder should fail validation"); + assert!(matches!( + result, + Err(NodeValidationError::InvalidProperties(ref msg)) + if msg.contains("empty") + )); + } + + #[test] + fn test_title_template_none_is_valid() { + use crate::models::SchemaNode; + let behavior = SchemaNodeBehavior; + + // No title_template field at all — should pass + let node = Node::new( + "schema".to_string(), + "Widget".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "", + "fields": [], + "relationships": [] + }), + ); + let schema = SchemaNode::from_node(node).unwrap(); + assert!( + behavior.validate_schema_node(&schema).is_ok(), + "Schema without title_template should pass validation" + ); + } + // ========================================================================= // TaskNode from_node Format Tests // ========================================================================= diff --git a/packages/core/src/models/core_schemas.rs b/packages/core/src/models/core_schemas.rs index e87395340..299f9c96a 100644 --- a/packages/core/src/models/core_schemas.rs +++ b/packages/core/src/models/core_schemas.rs @@ -167,6 +167,7 @@ pub fn get_core_schemas() -> Vec { }, ], relationships: vec![], + title_template: None, }, // Text schema - plain text content (no extra fields) SchemaNode { @@ -180,6 +181,7 @@ pub fn get_core_schemas() -> Vec { description: "Plain text content".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Date schema - daily note containers (no extra fields) SchemaNode { @@ -193,6 +195,7 @@ pub fn get_core_schemas() -> Vec { description: "Date node schema".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Header schema - markdown headers (no extra fields) SchemaNode { @@ -206,6 +209,7 @@ pub fn get_core_schemas() -> Vec { description: "Markdown header (h1-h6)".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Code block schema - code with syntax highlighting (no extra fields) SchemaNode { @@ -219,6 +223,7 @@ pub fn get_core_schemas() -> Vec { description: "Code block with syntax highlighting".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Quote block schema - blockquotes (no extra fields) SchemaNode { @@ -232,6 +237,7 @@ pub fn get_core_schemas() -> Vec { description: "Blockquote for citations".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Ordered list schema - numbered list items (no extra fields) SchemaNode { @@ -245,6 +251,7 @@ pub fn get_core_schemas() -> Vec { description: "Numbered list item".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Horizontal line schema - thematic break (no extra fields) SchemaNode { @@ -258,6 +265,7 @@ pub fn get_core_schemas() -> Vec { description: "Horizontal rule / thematic break".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Table schema - GFM markdown table (no extra fields) SchemaNode { @@ -271,6 +279,7 @@ pub fn get_core_schemas() -> Vec { description: "GFM markdown table with alignment support".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Collection schema - hierarchical labels for organizing nodes SchemaNode { @@ -284,6 +293,7 @@ pub fn get_core_schemas() -> Vec { description: "Hierarchical label for organizing nodes into groups".to_string(), fields: vec![], // Uses content for name relationships: vec![], // member_of is a native edge, not schema-defined + title_template: None, }, // Checkbox schema - pure content node with state encoded in content string SchemaNode { @@ -297,6 +307,7 @@ pub fn get_core_schemas() -> Vec { description: "Checkbox item — markdown annotation, not a managed task".to_string(), fields: vec![], relationships: vec![], + title_template: None, }, // Query schema - saved query definitions SchemaNode { @@ -440,6 +451,7 @@ pub fn get_core_schemas() -> Vec { }, ], relationships: vec![], + title_template: None, }, ] } diff --git a/packages/core/src/models/schema_node.rs b/packages/core/src/models/schema_node.rs index 88e3a02ce..1c1001ee1 100644 --- a/packages/core/src/models/schema_node.rs +++ b/packages/core/src/models/schema_node.rs @@ -96,6 +96,14 @@ pub struct SchemaNode { /// created when the schema is saved. See [`SchemaRelationship`] for details. #[serde(default)] pub relationships: Vec, + + /// Optional template for computing the node's indexed title from its properties. + /// + /// Uses `{field_name}` syntax, e.g. `"{first_name} {last_name} ({email})"`. + /// When set, title is interpolated from node properties instead of content. + /// Missing or null fields are replaced with empty strings. + #[serde(skip_serializing_if = "Option::is_none")] + pub title_template: Option, } fn default_version() -> i64 { @@ -156,6 +164,12 @@ impl SchemaNode { .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); + let title_template = node + .properties + .get("titleTemplate") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Ok(Self { id: node.id, content: node.content, @@ -167,6 +181,7 @@ impl SchemaNode { description, fields, relationships, + title_template, }) } @@ -174,7 +189,7 @@ impl SchemaNode { /// /// This creates a Node with properties populated from the strongly-typed fields. pub fn into_node(self) -> Node { - let properties = serde_json::json!({ + let mut properties = serde_json::json!({ "isCore": self.is_core, "schemaVersion": self.schema_version, "description": self.description, @@ -182,6 +197,10 @@ impl SchemaNode { "relationships": self.relationships, }); + if let Some(template) = self.title_template { + properties["titleTemplate"] = serde_json::Value::String(template); + } + Node { id: self.id, node_type: "schema".to_string(), diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs index b41cfeb89..2c64566af 100644 --- a/packages/core/src/services/node_service.rs +++ b/packages/core/src/services/node_service.rs @@ -1370,12 +1370,12 @@ impl NodeService { // NOTE: root_id filtering removed - hierarchy now managed via relationships // Issue #821: Populate title for @mention search - // Task nodes always get indexed (regardless of hierarchy level) - // Issue #844: Collection nodes always get titles (for indexed lookup) - // Other node types will have title set by create_node_with_parent if they're root nodes + // Issue #824: Schema-driven title_template support // Only set title if not already set (create_node_with_parent may have set it for root nodes) - if (node.node_type == "task" || node.node_type == "collection") && node.title.is_none() { - node.title = Some(crate::utils::strip_markdown(&node.content)); + if node.title.is_none() { + // For task/collection we know they're always titled; for others we need to check + // is_root=None will only trigger a DB lookup for non-task/collection/date/schema types + node.title = self.compute_title(&node, None).await?; } // For schema nodes, use atomic creation with DDL generation (Issue #691, #703) @@ -1564,23 +1564,35 @@ impl NodeService { let node_type = params.node_type.clone(); // Issue #821: Determine title for @mention search - // Title is set for: task nodes (always), collection nodes (always), OR root nodes (no parent) - // Note: create_node will also set title for task nodes, but we set it here too - // for root non-task nodes that won't go through the task check in create_node - // TODO #824: Refactor to schema-driven title_template approach - // Issue #844: Collections now get titles (for indexed lookup) but are excluded from @mention - let title = if params.node_type == "task" - || params.node_type == "collection" - || params.parent_id.is_none() - { - // Exclude certain types from having titles - // TODO #824: Replace hardcoded exclusions with schema-driven title_template - match params.node_type.as_str() { - "date" | "schema" => None, - _ => Some(crate::utils::strip_markdown(¶ms.content)), - } - } else { - None + // Issue #824: Schema-driven title_template support + // Normalize properties to namespaced format so compute_title can find fields correctly. + // (create_node will normalize again, but the result is idempotent) + let title = { + let normalized_props = if params.node_type != "schema" { + Self::normalize_flat_properties_to_namespace( + ¶ms.node_type, + ¶ms.properties, + None, + ) + } else { + params.properties.clone() + }; + let temp_node = Node { + id: node_id.clone(), + node_type: params.node_type.clone(), + content: params.content.clone(), + version: 1, + properties: normalized_props, + mentions: vec![], + mentioned_in: vec![], + created_at: chrono::Utc::now(), + modified_at: chrono::Utc::now(), + title: None, + lifecycle_status: "active".to_string(), + }; + // is_root = parent_id.is_none() — avoids a DB lookup at create time + self.compute_title(&temp_node, Some(params.parent_id.is_none())) + .await? }; let node = Node { @@ -2096,6 +2108,77 @@ impl NodeService { }) } + /// Compute the indexed title for a node (Issue #824). + /// + /// Priority: + /// 1. Schema has `title_template` → interpolate from properties + /// 2. task/collection type → `strip_markdown(content)` + /// 3. Root node (no parent), not date/schema → `strip_markdown(content)` + /// 4. Otherwise → `None` + /// + /// The `is_root` parameter avoids a redundant DB lookup when the caller already + /// knows the root status (e.g. `parent_id.is_none()` at creation time). Pass + /// `None` to have this method look it up only when needed. + async fn compute_title( + &self, + node: &Node, + is_root: Option, + ) -> Result, NodeServiceError> { + // date/schema nodes never get titles regardless of template + if node.node_type == "date" || node.node_type == "schema" { + return Ok(None); + } + + // Check for title_template in the schema for this node type + match self.get_schema_node(&node.node_type).await { + Ok(Some(schema)) => { + if let Some(template) = &schema.title_template { + // Properties are stored namespaced: { "node_type": { "field": value } } + // Unwrap to the inner namespace object for template interpolation + let flat_props = node + .properties + .get(&node.node_type) + .unwrap_or(&node.properties); + return Ok(Some(crate::utils::interpolate_title_template( + template, flat_props, + ))); + } + } + Ok(None) => {} // No schema for this type — fall through to content-based logic + Err(e) => { + // Schema lookup failed; fall through to content-based title rather than + // blocking the create/update operation + tracing::warn!( + node_type = %node.node_type, + error = %e, + "compute_title: schema lookup failed, falling back to content-based title" + ); + } + } + + // Fall back to content-based title + let title = match node.node_type.as_str() { + "task" | "collection" => Some(crate::utils::strip_markdown(&node.content)), + _ => { + let root = match is_root { + Some(v) => v, + None => self + .store + .get_parent_id(&node.id) + .await + .map_err(|e| NodeServiceError::query_failed(e.to_string()))? + .is_none(), + }; + if root { + Some(crate::utils::strip_markdown(&node.content)) + } else { + None + } + } + }; + Ok(title) + } + /// Update a node without version checking (no OCC). /// /// **Prefer `update_node()`** which enforces optimistic concurrency control. @@ -2156,6 +2239,7 @@ impl NodeService { let mut updated = existing.clone(); let mut content_changed = false; let mut node_type_changed = false; + let mut properties_changed = false; if let Some(node_type) = update.node_type { node_type_changed = updated.node_type != node_type; @@ -2173,6 +2257,7 @@ impl NodeService { // Use reorder_siblings() or move_node() for ordering changes. if let Some(properties) = update.properties { + properties_changed = true; // Issue #838: Normalize flat client properties to namespaced format before merging // Skip for schema nodes - they use a special non-namespaced format if updated.node_type == "schema" { @@ -2221,33 +2306,11 @@ impl NodeService { self.validate_node_against_schema(&updated).await?; } - // Issue #821: Sync title when content or node_type changes - // Title is indexed for @mention search on task nodes and root nodes - // Issue #844: Collection nodes now get titles (for indexed lookup) - // TODO #824: Refactor to schema-driven title_template approach - let title_update = if content_changed || node_type_changed { - // Determine if this node should have a title - // 1. Task nodes always get titles (regardless of hierarchy) - // 2. Collection nodes always get titles (for indexed lookup, but excluded from @mention) - // 3. Root nodes (no parent) get titles - // 4. Date and schema nodes never get titles - // TODO #824: Replace hardcoded exclusions with schema-driven title_template - let should_have_title = match updated.node_type.as_str() { - "date" | "schema" => false, - "task" | "collection" => true, - _ => { - // Check if root node (no parent) - self.get_parent(id).await?.is_none() - } - }; - - if should_have_title { - Some(Some(crate::utils::strip_markdown(&updated.content))) - } else { - // Clear title for nodes that shouldn't have one - // (e.g., when changing from task to text child node) - Some(None) - } + // Issue #821: Sync title when content, node_type, or properties change + // Issue #824: Schema-driven title_template — also trigger on properties_changed + let title_update = if content_changed || node_type_changed || properties_changed { + let new_title = self.compute_title(&updated, None).await?; + Some(new_title) } else { None // No title update needed }; @@ -2356,6 +2419,7 @@ impl NodeService { let mut updated = existing.clone(); let mut content_changed = false; let mut node_type_changed = false; + let mut properties_changed = false; if let Some(node_type) = update.node_type { node_type_changed = updated.node_type != node_type; @@ -2373,6 +2437,7 @@ impl NodeService { // Use reorder_siblings() or move_node() for ordering changes. if let Some(properties) = update.properties { + properties_changed = true; // Issue #838: Normalize flat client properties to namespaced format before merging // Skip for schema nodes - they use a special non-namespaced format if updated.node_type == "schema" { @@ -2403,29 +2468,11 @@ impl NodeService { self.validate_node_against_schema(&updated).await?; } - // Issue #821: Sync title when content or node_type changes - // Title is indexed for @mention search on task nodes and root nodes - // TODO #824: Refactor to schema-driven title_template approach - let title_update = if content_changed || node_type_changed { - // Determine if this node should have a title - // TODO #824: Replace hardcoded exclusions with schema-driven title_template - // Use optimized get_parent_id() instead of get_parent() for root check - let should_have_title = match updated.node_type.as_str() { - "date" | "schema" | "collection" => false, - "task" => true, - _ => self - .store - .get_parent_id(id) - .await - .map_err(|e| NodeServiceError::query_failed(e.to_string()))? - .is_none(), - }; - - if should_have_title { - Some(Some(crate::utils::strip_markdown(&updated.content))) - } else { - Some(None) - } + // Issue #821: Sync title when content, node_type, or properties change + // Issue #824: Schema-driven title_template — also trigger on properties_changed + let title_update = if content_changed || node_type_changed || properties_changed { + let new_title = self.compute_title(&updated, None).await?; + Some(new_title) } else { None }; @@ -8851,7 +8898,11 @@ mod tests { { "name": "status", "type": "enum", - "required": true + "required": true, + "coreValues": [ + { "value": "open", "label": "Open" }, + { "value": "done", "label": "Done" } + ] } ], "relationships": [ @@ -10533,4 +10584,190 @@ mod tests { ); } } + + mod title_template_tests { + use super::*; + + /// Helper: create a custom schema with the given title_template + async fn create_custom_schema(service: &NodeService, type_id: &str, title_template: &str) { + let schema_node = Node::new_with_id( + type_id.to_string(), + "schema".to_string(), + type_id.to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": format!("Test schema for {}", type_id), + "titleTemplate": title_template, + "fields": [], + "relationships": [] + }), + ); + service.create_node(schema_node).await.unwrap(); + } + + /// Test: creating a node of a custom type with title_template computes title from properties + #[tokio::test] + async fn test_title_template_on_create() { + let (service, _temp) = create_test_service().await; + + create_custom_schema(&service, "customer", "{first_name} {last_name}").await; + + let node = Node::new_with_id( + uuid::Uuid::new_v4().to_string(), + "customer".to_string(), + "".to_string(), + json!({"first_name": "John", "last_name": "Doe"}), + ); + let node_id = node.id.clone(); + service.create_node(node).await.unwrap(); + + let retrieved = service.get_node(&node_id).await.unwrap().unwrap(); + assert_eq!( + retrieved.title, + Some("John Doe".to_string()), + "Title should be interpolated from template on create" + ); + } + + /// Test: updating node properties triggers title recomputation via template + #[tokio::test] + async fn test_title_template_recomputed_on_property_update() { + let (service, _temp) = create_test_service().await; + + create_custom_schema(&service, "customer2", "{first_name} {last_name}").await; + + let node = Node::new_with_id( + uuid::Uuid::new_v4().to_string(), + "customer2".to_string(), + "".to_string(), + json!({"first_name": "Jane", "last_name": "Smith"}), + ); + let node_id = node.id.clone(); + service.create_node(node).await.unwrap(); + + // Verify initial title + let initial = service.get_node(&node_id).await.unwrap().unwrap(); + assert_eq!(initial.title, Some("Jane Smith".to_string())); + + // Update properties → title should recompute + let update = crate::models::NodeUpdate { + properties: Some(json!({"first_name": "Janet"})), + ..Default::default() + }; + service + .update_node_unchecked(&node_id, update) + .await + .unwrap(); + + let updated = service.get_node(&node_id).await.unwrap().unwrap(); + assert_eq!( + updated.title, + Some("Janet Smith".to_string()), + "Title should recompute when properties change" + ); + } + + /// Test: updating content does NOT override template-based title + #[tokio::test] + async fn test_title_template_takes_priority_over_content() { + let (service, _temp) = create_test_service().await; + + create_custom_schema(&service, "customer3", "{first_name} {last_name}").await; + + let node = Node::new_with_id( + uuid::Uuid::new_v4().to_string(), + "customer3".to_string(), + "original content".to_string(), + json!({"first_name": "Alice", "last_name": "Wonder"}), + ); + let node_id = node.id.clone(); + service.create_node(node).await.unwrap(); + + // Verify template wins over content on create + let initial = service.get_node(&node_id).await.unwrap().unwrap(); + assert_eq!(initial.title, Some("Alice Wonder".to_string())); + + // Update content → template should still produce the title + let update = crate::models::NodeUpdate { + content: Some("updated content".to_string()), + ..Default::default() + }; + service + .update_node_unchecked(&node_id, update) + .await + .unwrap(); + + let updated = service.get_node(&node_id).await.unwrap().unwrap(); + assert_eq!( + updated.title, + Some("Alice Wonder".to_string()), + "Template-based title should take priority over content" + ); + } + + /// Test: node type without template falls back to existing content-based behavior + #[tokio::test] + async fn test_no_title_template_falls_back_to_content() { + let (service, _temp) = create_test_service().await; + + // Create a schema without title_template + let schema_node = Node::new_with_id( + "widget".to_string(), + "schema".to_string(), + "Widget".to_string(), + json!({ + "isCore": false, + "schemaVersion": 1, + "description": "Widget schema with no title_template", + "fields": [], + "relationships": [] + }), + ); + service.create_node(schema_node).await.unwrap(); + + // Root widget node (no parent) should get content as title + let params = CreateNodeParams { + node_type: "widget".to_string(), + content: "My Widget".to_string(), + parent_id: None, + insert_after_node_id: None, + properties: json!({}), + id: None, + }; + let widget_id = service.create_node_with_parent(params).await.unwrap(); + let retrieved = service.get_node(&widget_id).await.unwrap().unwrap(); + assert_eq!( + retrieved.title, + Some("My Widget".to_string()), + "Root node without template should use content as title" + ); + } + + /// Test: missing template fields produce empty strings (not panics) + #[tokio::test] + async fn test_title_template_missing_fields_graceful() { + let (service, _temp) = create_test_service().await; + + create_custom_schema(&service, "contact", "{first_name} {last_name} ({email})").await; + + // Create node with only first_name — last_name and email missing + let node = Node::new_with_id( + uuid::Uuid::new_v4().to_string(), + "contact".to_string(), + "".to_string(), + json!({"first_name": "Bob"}), + ); + let node_id = node.id.clone(); + service.create_node(node).await.unwrap(); + + let retrieved = service.get_node(&node_id).await.unwrap().unwrap(); + // Missing fields become empty strings, whitespace is collapsed and trimmed + assert_eq!( + retrieved.title, + Some("Bob ()".to_string()), + "Missing fields should produce empty strings, not errors" + ); + } + } } diff --git a/packages/core/src/utils/markdown.rs b/packages/core/src/utils/markdown.rs index 316637667..2860cc1d3 100644 --- a/packages/core/src/utils/markdown.rs +++ b/packages/core/src/utils/markdown.rs @@ -96,6 +96,62 @@ pub fn strip_markdown(content: &str) -> String { result.trim().to_string() } +/// Interpolate a title template string using node properties. +/// +/// Replaces `{field_name}` tokens with the corresponding property value. +/// Non-string values (numbers, booleans) are converted to their string representation. +/// Missing or null fields are replaced with empty strings. +/// The result is trimmed of leading/trailing whitespace. +/// +/// # Arguments +/// +/// * `template` - The template string with `{field_name}` placeholders +/// * `properties` - The node's properties JSON object +/// +/// # Examples +/// +/// ``` +/// use nodespace_core::utils::interpolate_title_template; +/// use serde_json::json; +/// +/// let props = json!({"first_name": "John", "last_name": "Doe"}); +/// assert_eq!( +/// interpolate_title_template("{first_name} {last_name}", &props), +/// "John Doe" +/// ); +/// ``` +pub fn interpolate_title_template(template: &str, properties: &serde_json::Value) -> String { + let mut result = String::with_capacity(template.len()); + let chars: Vec = template.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '{' { + // Find the closing brace + if let Some(end) = chars[i + 1..].iter().position(|&c| c == '}') { + let field_name: String = chars[i + 1..i + 1 + end].iter().collect(); + // Look up the field value in properties + let value = properties.get(&field_name); + match value { + Some(serde_json::Value::String(s)) => result.push_str(s), + Some(serde_json::Value::Number(n)) => result.push_str(&n.to_string()), + Some(serde_json::Value::Bool(b)) => result.push_str(&b.to_string()), + Some(serde_json::Value::Null) | None => {} // empty string for missing/null + Some(other) => result.push_str(&other.to_string()), + } + i += 1 + end + 1; // skip past '}' + continue; + } + } + result.push(chars[i]); + i += 1; + } + + // Normalize whitespace and trim + let normalized = WHITESPACE_RE.replace_all(&result, " "); + normalized.trim().to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -212,4 +268,93 @@ mod tests { let expected = "Header Some bold text List item"; assert_eq!(strip_markdown(input), expected); } + + // ------------------------------------------------------------------------- + // interpolate_title_template tests + // ------------------------------------------------------------------------- + + #[test] + fn test_interpolate_basic_fields() { + let props = serde_json::json!({"first_name": "John", "last_name": "Doe"}); + assert_eq!( + interpolate_title_template("{first_name} {last_name}", &props), + "John Doe" + ); + } + + #[test] + fn test_interpolate_missing_field_is_empty() { + let props = serde_json::json!({"first_name": "Jane"}); + assert_eq!( + interpolate_title_template("{first_name} {last_name}", &props), + "Jane" + ); + } + + #[test] + fn test_interpolate_null_field_is_empty() { + let props = serde_json::json!({"first_name": "Alice", "email": null}); + assert_eq!( + interpolate_title_template("{first_name} ({email})", &props), + "Alice ()" + ); + } + + #[test] + fn test_interpolate_number_value() { + let props = serde_json::json!({"invoice_number": 42}); + assert_eq!( + interpolate_title_template("Invoice #{invoice_number}", &props), + "Invoice #42" + ); + } + + #[test] + fn test_interpolate_boolean_value() { + let props = serde_json::json!({"active": true}); + assert_eq!( + interpolate_title_template("Active: {active}", &props), + "Active: true" + ); + } + + #[test] + fn test_interpolate_multiple_fields() { + let props = serde_json::json!({ + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com" + }); + assert_eq!( + interpolate_title_template("{first_name} {last_name} ({email})", &props), + "John Doe (john@example.com)" + ); + } + + #[test] + fn test_interpolate_trims_whitespace() { + let props = serde_json::json!({"first_name": "Jane", "last_name": ""}); + assert_eq!( + interpolate_title_template("{first_name} {last_name}", &props), + "Jane" + ); + } + + #[test] + fn test_interpolate_no_placeholders() { + let props = serde_json::json!({}); + assert_eq!( + interpolate_title_template("Static Title", &props), + "Static Title" + ); + } + + #[test] + fn test_interpolate_all_fields_missing() { + let props = serde_json::json!({}); + assert_eq!( + interpolate_title_template("{first_name} {last_name}", &props), + "" + ); + } } diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs index 779ad394b..3eea1edd6 100644 --- a/packages/core/src/utils/mod.rs +++ b/packages/core/src/utils/mod.rs @@ -4,4 +4,4 @@ mod markdown; -pub use markdown::strip_markdown; +pub use markdown::{interpolate_title_template, strip_markdown};