From f624b26263c64a3630c813c5ac01e150c34568d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Sun, 21 Dec 2025 14:04:24 +0000 Subject: [PATCH] feat(form-parser): constraint interpolation with single source of truth - Add resolve_constraints_in_content() to handle ${constraint.path} patterns - Integrate into all form loading functions (load_from_file, load_fragment_form, etc) - Support nested path navigation (e.g., constraints.tracker.udp.max_items) - Add test_constraint_interpolation() test fix(nickel-roundtrip): apply constraint interpolation in roundtrip workflow - Fix execute_form() to use load_from_file() instead of parse_toml() - Ensures constraints are resolved in roundtrip mode docs(examples): add constraint interpolation example - Create examples/05-fragments/constraints.toml - Update examples/05-fragments/array-trackers.toml to use ${constraint.*} - Document constraint workflow in examples/05-fragments/README.md Benefits: - Single source of truth for validation limits - Forms auto-resolve constraints at load time - All layers (Forms, Nickel, Templates) sync automatically --- crates/typedialog-core/src/form_parser.rs | 181 +++++++++++++++++- .../typedialog-core/src/nickel/roundtrip.rs | 12 +- examples/05-fragments/README.md | 63 +++++- examples/05-fragments/array-trackers.toml | 8 +- examples/05-fragments/constraints.toml | 20 ++ 5 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 examples/05-fragments/constraints.toml diff --git a/crates/typedialog-core/src/form_parser.rs b/crates/typedialog-core/src/form_parser.rs index ee56094..153c7f0 100644 --- a/crates/typedialog-core/src/form_parser.rs +++ b/crates/typedialog-core/src/form_parser.rs @@ -7,7 +7,7 @@ use crate::i18n::I18nBundle; use crate::prompts; use serde::{de, Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, HashMap}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Default order for form elements (auto-assigned based on array position) fn default_order() -> usize { @@ -568,6 +568,82 @@ pub enum DisplayMode { FieldByField, } +/// Resolve constraint interpolations in TOML content +/// Replaces "${constraint.path.to.value}" (with quotes) with actual values from constraints.toml +/// The quotes are removed as part of the replacement, so the value becomes a bare number +fn resolve_constraints_in_content( + content: &str, + base_dir: &Path, +) -> Result { + let constraints_path = base_dir.join("constraints.toml"); + + // If constraints.toml doesn't exist, return content unchanged + if !constraints_path.exists() { + return Ok(content.to_string()); + } + + let constraints_content = std::fs::read_to_string(&constraints_path)?; + let constraints_table: toml::Table = toml::from_str(&constraints_content).map_err(|e| { + crate::error::Error::validation_failed(format!("Failed to parse constraints.toml: {}", e)) + })?; + + let mut result = content.to_string(); + + // Find all "${constraint.*}" patterns (with quotes) by searching + // Pattern format: max_items = "${constraint.tracker.udp.max_items}" + while let Some(start_pos) = result.find("\"${constraint.") { + // Find the closing brace followed by quote sequence: }" + let search_start = start_pos + 2; // Skip the opening quote + if let Some(close_brace_pos) = result[search_start..].find("}\"") { + let close_brace_abs = search_start + close_brace_pos; + let end_pos = close_brace_abs + 1; // Position of the closing quote + + let pattern = &result[start_pos..=end_pos]; + // Extract path between "${constraint. and }" + // pattern looks like: "${constraint.tracker.udp.max_items}" + // We skip the first 14 chars ("${constraint.) and last 2 chars (}") + let constraint_path = &pattern[14..pattern.len() - 2]; + + // Navigate through the table following the path + let path_parts: Vec<&str> = constraint_path.split('.').collect(); + let mut current: &toml::Value = &toml::Value::Table(constraints_table.clone()); + let mut found = true; + + for part in path_parts { + if let toml::Value::Table(table) = current { + if let Some(next) = table.get(part) { + current = next; + } else { + found = false; + break; + } + } else { + found = false; + break; + } + } + + if found { + if let toml::Value::Integer(n) = current { + // Replace the quoted interpolation with just the number (unquoted) + // This allows TOML to parse it as a number, not a string + let replacement = n.to_string(); + result.replace_range(start_pos..=end_pos, &replacement); + // Continue searching from the current position + continue; + } + } + + // If not found or not an integer, break to avoid infinite loop + break; + } else { + break; // No closing sequence found + } + } + + Ok(result) +} + /// Parse TOML string into a FormDefinition pub fn parse_toml(content: &str) -> Result { toml::from_str(content).map_err(|e| e.into()) @@ -579,18 +655,28 @@ pub fn load_and_execute_from_file( ) -> Result> { let path_ref = path.as_ref(); let content = std::fs::read_to_string(path_ref)?; - let form = parse_toml(&content)?; // Get the directory of the current file for relative path resolution let base_dir = path_ref.parent().unwrap_or_else(|| Path::new(".")); + // Resolve constraint interpolations before parsing + let resolved_content = resolve_constraints_in_content(&content, base_dir)?; + let form = parse_toml(&resolved_content)?; + execute_with_base_dir(form, base_dir) } /// Load form from TOML file (returns FormDefinition, doesn't execute) pub fn load_from_file(path: impl AsRef) -> Result { - let content = std::fs::read_to_string(path)?; - parse_toml(&content) + let path_ref = path.as_ref(); + let content = std::fs::read_to_string(path_ref)?; + + // Get the directory of the current file for relative path resolution + let base_dir = path_ref.parent().unwrap_or_else(|| Path::new(".")); + + // Resolve constraint interpolations before parsing + let resolved_content = resolve_constraints_in_content(&content, base_dir)?; + parse_toml(&resolved_content) } /// Extract field name from a condition string @@ -706,7 +792,16 @@ pub fn should_load_fragment( /// A FormDefinition with migrated elements pub fn load_fragment_form(path: &str) -> Result { let content = std::fs::read_to_string(path)?; - let mut form: FormDefinition = toml::from_str(&content)?; + + // Get the directory of the fragment file for constraint resolution + let fragment_dir = PathBuf::from(path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Resolve constraint interpolations before parsing + let resolved_content = resolve_constraints_in_content(&content, &fragment_dir)?; + let mut form: FormDefinition = toml::from_str(&resolved_content)?; form.migrate_to_elements(); Ok(form) } @@ -721,7 +816,10 @@ fn load_elements_from_file(path: &str, base_dir: &Path) -> Result Result> base_dir.join(path) }; let content = std::fs::read_to_string(&resolved_path)?; - let form: FormDefinition = toml::from_str(&content)?; + + // Resolve constraint interpolations before parsing + let resolved_content = resolve_constraints_in_content(&content, base_dir)?; + let form: FormDefinition = toml::from_str(&resolved_content)?; Ok(form.items) } @@ -748,7 +849,10 @@ fn load_fields_from_file(path: &str, base_dir: &Path) -> Result Result> { - // Read form definition - let form_content = fs::read_to_string(form_path).map_err(|e| { - crate::error::Error::new( - crate::error::ErrorKind::Other, - format!("Failed to read form file: {}", e), - ) - })?; - - // Parse TOML form definition - let form = form_parser::parse_toml(&form_content)?; + // Load form definition from file (resolves constraint interpolations + includes) + let form = form_parser::load_from_file(form_path)?; // Extract base directory for resolving relative paths (includes, fragments) let base_dir = form_path.parent().unwrap_or_else(|| Path::new(".")); diff --git a/examples/05-fragments/README.md b/examples/05-fragments/README.md index cdca60d..08fc192 100644 --- a/examples/05-fragments/README.md +++ b/examples/05-fragments/README.md @@ -1,6 +1,6 @@ # Form Fragments & Composition -Reusable form components and modular form building. +Reusable form components, modular form building, and constraint management. ## Files @@ -16,6 +16,10 @@ Reusable form components and modular form building. ### Examples Using Fragments - **form_with_groups_includes.toml** - Demonstrates fragment inclusion +- **array-trackers.toml** - RepeatingGroup arrays with constraint interpolation + +### Constraint Configuration +- **constraints.toml** - Single source of truth for array validation limits ## Usage Pattern @@ -39,13 +43,68 @@ title = "Employee Onboarding" cargo run --example form_with_groups_includes ``` -## Benefits +## Constraint Interpolation Example + +### What is Constraint Interpolation? + +Instead of hardcoding validation limits in forms, you can use constraint variables: + +```toml +# array-trackers.toml +[[elements]] +name = "udp_trackers" +type = "repeatinggroup" +max_items = "${constraint.tracker.udp.max_items}" # ← Dynamic constraint reference +``` + +### Single Source of Truth + +All validation limits are defined in **one file**: + +```toml +# constraints.toml +[tracker.udp] +max_items = 4 # Change this value, form auto-updates! +``` + +### How It Works + +1. **Form loads** - `array-trackers.toml` contains `"${constraint.tracker.udp.max_items}"` +2. **Parser resolves** - Form parser finds `constraints.toml` in same directory +3. **Value injected** - The constraint value (4) replaces the placeholder +4. **Form validates** - User can add up to 4 UDP trackers + +### Try It + +```bash +# Run the example +cargo run --example array-trackers + +# The form will allow: +# - 0-4 UDP trackers (from constraints.tracker.udp.max_items) +# - 0-4 HTTP trackers (from constraints.tracker.http.max_items) + +# Change constraints.toml: +# [tracker.udp] +# max_items = 6 # ← Change from 4 to 6 +# +# Re-run the example - form now allows 0-6 UDP trackers! +``` + +### Key Files + +- **constraints.toml** - Defines validation limits +- **array-trackers.toml** - References constraints via `${constraint.path}` +- Form parser automatically resolves interpolations at load time + +## Benefits of Constraints - **DRY** - Write common sections once - **Maintainability** - Update fragments in one place - **Reusability** - Share across multiple forms - **Modularity** - Compose complex forms from simple parts - **Consistency** - Unified styling and structure +- **Constraint Sync** - Single place to change validation limits (all layers auto-sync) ## Fragment Library diff --git a/examples/05-fragments/array-trackers.toml b/examples/05-fragments/array-trackers.toml index d94811e..bff3bee 100644 --- a/examples/05-fragments/array-trackers.toml +++ b/examples/05-fragments/array-trackers.toml @@ -30,13 +30,15 @@ default = "public" order = 1 # UDP Trackers array +# max_items loaded from constraints.toml via constraint interpolation +# See constraints.toml to change max_items (default: 4) [[elements]] name = "udp_trackers" type = "repeatinggroup" prompt = "UDP Tracker Listeners" fragment = "fragments/tracker-udp-item.toml" min_items = 0 -max_items = 10 +max_items = "${constraint.tracker.udp.max_items}" default_items = 1 unique = true required = false @@ -44,13 +46,15 @@ help = "Add UDP tracker listener addresses (must be unique). Standard BitTorrent order = 2 # HTTP Trackers array +# max_items loaded from constraints.toml via constraint interpolation +# See constraints.toml to change max_items (default: 4) [[elements]] name = "http_trackers" type = "repeatinggroup" prompt = "HTTP Tracker Listeners" fragment = "fragments/tracker-http-item.toml" min_items = 0 -max_items = 10 +max_items = "${constraint.tracker.http.max_items}" default_items = 1 unique = true required = false diff --git a/examples/05-fragments/constraints.toml b/examples/05-fragments/constraints.toml new file mode 100644 index 0000000..a72ec73 --- /dev/null +++ b/examples/05-fragments/constraints.toml @@ -0,0 +1,20 @@ +# Validation Constraints for Tracker Arrays +# Single source of truth for min/max items and uniqueness rules +# +# This file is imported by array-trackers.toml via constraint interpolation: +# max_items = "${constraint.tracker.udp.max_items}" +# max_items = "${constraint.tracker.http.max_items}" +# +# Change these values and the form will automatically use them! + +[tracker.udp] +# UDP tracker listeners - BitTorrent standard port 6969 +min_items = 0 +max_items = 4 +unique = true + +[tracker.http] +# HTTP tracker listeners - Standard HTTP/HTTPS ports 80/443 +min_items = 0 +max_items = 4 +unique = true