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:
parent
6d045d62c9
commit
f624b26263
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("."));
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
20
examples/05-fragments/constraints.toml
Normal file
20
examples/05-fragments/constraints.toml
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user