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
This commit is contained in:
Jesús Pérez 2025-12-21 14:04:24 +00:00
parent 6d045d62c9
commit f624b26263
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
5 changed files with 262 additions and 22 deletions

View File

@ -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<String> {
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<FormDefinition> {
toml::from_str(content).map_err(|e| e.into())
@ -579,18 +655,28 @@ pub fn load_and_execute_from_file(
) -> Result<HashMap<String, serde_json::Value>> {
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<Path>) -> Result<FormDefinition> {
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<FormDefinition> {
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<Vec<FormElemen
base_dir.join(path)
};
let content = std::fs::read_to_string(&resolved_path)?;
let mut form: FormDefinition = toml::from_str(&content)?;
// Resolve constraint interpolations before parsing
let resolved_content = resolve_constraints_in_content(&content, base_dir)?;
let mut form: FormDefinition = toml::from_str(&resolved_content)?;
form.migrate_to_elements();
Ok(form.elements)
}
@ -735,7 +833,10 @@ fn load_items_from_file(path: &str, base_dir: &Path) -> Result<Vec<DisplayItem>>
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<Vec<FieldDefinit
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.fields)
}
@ -3041,4 +3145,65 @@ mod integration_tests {
Some("mysql")
);
}
#[test]
fn test_constraint_interpolation() {
use std::fs;
use tempfile::TempDir;
// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
// Create constraints.toml
let constraints_content = r#"
[tracker]
[tracker.udp]
min_items = 1
max_items = 4
unique = true
[tracker.http]
min_items = 1
max_items = 6
unique = true
"#;
fs::write(temp_path.join("constraints.toml"), constraints_content).unwrap();
// Create a form with constraint interpolation
let form_content = r#"
name = "Test Constraints Form"
description = "Form with constraint interpolation"
[[elements]]
name = "udp_items"
type = "repeatinggroup"
prompt = "UDP Trackers"
min_items = 0
max_items = "${constraint.tracker.udp.max_items}"
"#;
fs::write(temp_path.join("test_form.toml"), form_content).unwrap();
// Load the form from file (this will apply constraint interpolation)
let form = load_from_file(temp_path.join("test_form.toml")).unwrap();
// Verify the interpolation worked
assert_eq!(form.name, "Test Constraints Form");
let udp_field = form
.elements
.iter()
.find(|e| {
if let FormElement::Field(f) = e {
f.name == "udp_items"
} else {
false
}
});
assert!(udp_field.is_some());
let field = udp_field.unwrap().as_field().unwrap();
// The max_items should have been resolved to 4
assert_eq!(field.max_items, Some(4));
}
}

View File

@ -351,16 +351,8 @@ impl RoundtripConfig {
/// Execute a form and return results (CLI backend only)
fn execute_form(form_path: &Path) -> Result<HashMap<String, Value>> {
// 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("."));

View File

@ -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

View File

@ -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

View File

@ -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