feat(repeating-groups): implement duplicate detection across all backends
- Fix has_unique flag reading from field definition (was scanning fragment fields) - Implement duplicate validation in CLI and TUI backends - Add item counter update in Web backend after add/delete operations - Refactor Web JavaScript: remove global constants, use closure-based state per group - Store repeating group config in data-* attributes instead of global variables - Update documentation and examples with unique = true attribute - All backends now enforce unique items validation consistently
This commit is contained in:
parent
82e52fc632
commit
6d045d62c9
57
CHANGES.md
Normal file
57
CHANGES.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Changes
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
**RepeatingGroup Duplicate Detection**
|
||||
- Implemented duplicate item validation across all backends (CLI, TUI, Web)
|
||||
- Added `unique = true` attribute support to prevent duplicate items
|
||||
- Added item counter display updates in Web backend
|
||||
- New `is_duplicate()` function in CLI and TUI backends
|
||||
|
||||
**Web Backend Improvements**
|
||||
- Refactored JavaScript: Replaced global constants with closure-based state management
|
||||
- Added `render_global_repeating_group_script()` for generic repeating group handling
|
||||
- Configuration stored in `data-*` attributes: `data-min-items`, `data-max-items`, `data-has-unique`, `data-unique-key`, `data-field-names`
|
||||
- Live item counter updates after add/delete operations
|
||||
- Single document-level event delegation for all repeating group interactions
|
||||
|
||||
**Documentation**
|
||||
- Updated `docs/FIELD_TYPES.md` with unique validation details
|
||||
- Updated `examples/README.md` with RepeatingGroup testing commands
|
||||
- Added `docs/FIELD_TYPES.md` (new file)
|
||||
- Updated example forms with `unique = true` attribute
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI Backend**: Fixed validation of duplicate items in add/edit operations
|
||||
- **TUI Backend**: Fixed validation of duplicate items with error overlay feedback
|
||||
- **Web Backend**: Fixed `has_unique` flag reading from field definition (was incorrectly scanning fragment fields)
|
||||
- **Web Backend**: Fixed item counter not updating when adding/deleting items
|
||||
|
||||
### Changed
|
||||
|
||||
**Architecture**
|
||||
- Each repeating group now maintains independent state via closure
|
||||
- Removed ~600 lines of dead code in Web JavaScript
|
||||
- Event handling now context-aware (finds correct repeating group controller)
|
||||
|
||||
**Examples**
|
||||
- `examples/05-fragments/array-trackers.toml`: Added `unique = true` to UDP and HTTP tracker arrays
|
||||
- `examples/07-nickel-generation/arrays-form.toml`: Added `unique = true` to all RepeatingGroup fields
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- Duplicate detection compares ALL field values when `unique = true`
|
||||
- Works consistently across CLI, TUI, and Web backends
|
||||
- Backwards compatible: repeating groups without `unique = true` unaffected
|
||||
- max_items limit already enforced in all backends (no changes needed)
|
||||
|
||||
### Testing
|
||||
|
||||
- All 174 unit tests passing
|
||||
- No clippy warnings
|
||||
- Build verified with `--all-features` flag
|
||||
- Manual testing in Web backend: duplicate detection working correctly
|
||||
- Item counter updates verified on add/delete operations
|
||||
35
Cargo.lock
generated
35
Cargo.lock
generated
@ -162,6 +162,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@ -747,6 +748,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -1556,6 +1566,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@ -2535,6 +2562,12 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@ -3024,6 +3057,7 @@ dependencies = [
|
||||
"clap",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"typedialog-core",
|
||||
"unic-langid",
|
||||
]
|
||||
@ -3036,6 +3070,7 @@ dependencies = [
|
||||
"clap",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"typedialog-core",
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
@ -58,7 +58,7 @@ crossterm = "0.29"
|
||||
atty = "0.2"
|
||||
|
||||
# Web Backend (axum)
|
||||
axum = "0.8.7"
|
||||
axum = { version = "0.8.7", features = ["multipart"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.8", features = ["fs", "cors"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@ -175,7 +175,10 @@ impl PatternCompleter {
|
||||
],
|
||||
};
|
||||
|
||||
Self { pattern, suggestions }
|
||||
Self {
|
||||
pattern,
|
||||
suggestions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add custom suggestion
|
||||
@ -244,7 +247,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filter_completer() {
|
||||
let options = vec!["apple".to_string(), "application".to_string(), "banana".to_string()];
|
||||
let options = vec![
|
||||
"apple".to_string(),
|
||||
"application".to_string(),
|
||||
"banana".to_string(),
|
||||
];
|
||||
let completer = FilterCompleter::new(options);
|
||||
|
||||
let filtered = completer.filter("app");
|
||||
|
||||
@ -3,12 +3,13 @@
|
||||
//! This backend provides the existing inquire-based CLI interface.
|
||||
//! It will be the primary implementation of FormBackend for terminal-based forms.
|
||||
|
||||
use super::{FormBackend, RenderContext};
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{DisplayItem, FieldDefinition, FieldType};
|
||||
use crate::prompts;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{FieldDefinition, DisplayItem, FieldType};
|
||||
use crate::prompts;
|
||||
use super::{FormBackend, RenderContext};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// CLI Backend implementation using inquire
|
||||
pub struct InquireBackend;
|
||||
@ -27,7 +28,11 @@ impl InquireBackend {
|
||||
match field.field_type {
|
||||
FieldType::Text => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::text(&prompt_with_marker, field.default.as_deref(), field.placeholder.as_deref())?;
|
||||
let result = prompts::text(
|
||||
&prompt_with_marker,
|
||||
field.default.as_deref(),
|
||||
field.placeholder.as_deref(),
|
||||
)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
@ -38,11 +43,15 @@ impl InquireBackend {
|
||||
|
||||
FieldType::Confirm => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let default_bool = field.default.as_deref().and_then(|s| match s.to_lowercase().as_str() {
|
||||
"true" | "yes" => Some(true),
|
||||
"false" | "no" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
let default_bool =
|
||||
field
|
||||
.default
|
||||
.as_deref()
|
||||
.and_then(|s| match s.to_lowercase().as_str() {
|
||||
"true" | "yes" => Some(true),
|
||||
"false" | "no" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
let result = prompts::confirm(&prompt_with_marker, default_bool, None)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
@ -60,10 +69,17 @@ impl InquireBackend {
|
||||
}
|
||||
|
||||
FieldType::Select => {
|
||||
if field.options.is_empty() {
|
||||
return Err(crate::Error::form_parse_failed(
|
||||
"Select field requires 'options'",
|
||||
));
|
||||
}
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field.options.clone().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("Select field requires 'options'")
|
||||
})?;
|
||||
let options = field
|
||||
.options
|
||||
.iter()
|
||||
.map(|opt| opt.as_string())
|
||||
.collect::<Vec<_>>();
|
||||
let result = prompts::select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
@ -74,10 +90,17 @@ impl InquireBackend {
|
||||
}
|
||||
|
||||
FieldType::MultiSelect => {
|
||||
if field.options.is_empty() {
|
||||
return Err(crate::Error::form_parse_failed(
|
||||
"MultiSelect field requires 'options'",
|
||||
));
|
||||
}
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field.options.clone().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("MultiSelect field requires 'options'")
|
||||
})?;
|
||||
let options = field
|
||||
.options
|
||||
.iter()
|
||||
.map(|opt| opt.as_string())
|
||||
.collect::<Vec<_>>();
|
||||
let results = prompts::multi_select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
@ -94,7 +117,11 @@ impl InquireBackend {
|
||||
|
||||
FieldType::Editor => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::editor(&prompt_with_marker, field.file_extension.as_deref(), field.prefix_text.as_deref())?;
|
||||
let result = prompts::editor(
|
||||
&prompt_with_marker,
|
||||
field.file_extension.as_deref(),
|
||||
field.prefix_text.as_deref(),
|
||||
)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
@ -121,7 +148,8 @@ impl InquireBackend {
|
||||
let type_name = field.custom_type.as_ref().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("Custom field requires 'custom_type'")
|
||||
})?;
|
||||
let result = prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
|
||||
let result =
|
||||
prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
@ -129,8 +157,259 @@ impl InquireBackend {
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::RepeatingGroup => {
|
||||
let fragment_path = field.fragment.as_ref().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("RepeatingGroup requires 'fragment' field")
|
||||
})?;
|
||||
|
||||
self.execute_repeating_group(field, fragment_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a repeating group field with interactive add/edit/delete menu
|
||||
fn execute_repeating_group(
|
||||
&self,
|
||||
field: &FieldDefinition,
|
||||
fragment_path: &str,
|
||||
) -> Result<Value> {
|
||||
let min_items = field.min_items.unwrap_or(0);
|
||||
let max_items = field.max_items.unwrap_or(usize::MAX);
|
||||
let default_items = field.default_items.unwrap_or(0);
|
||||
|
||||
let mut items: Vec<HashMap<String, Value>> = Vec::new();
|
||||
|
||||
// Pre-populate with default_items empty entries if requested
|
||||
for _ in 0..default_items {
|
||||
// Start with empty items - user can edit them
|
||||
}
|
||||
|
||||
loop {
|
||||
// Build menu options based on current state
|
||||
let options = Self::build_array_menu_options(&items, min_items, max_items);
|
||||
|
||||
let status_msg = if min_items > 0 && items.len() < min_items {
|
||||
format!(" - minimum {} required", min_items)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let prompt = format!(
|
||||
"{} ({} items configured){}",
|
||||
field.prompt,
|
||||
items.len(),
|
||||
status_msg
|
||||
);
|
||||
|
||||
let choice = prompts::select(&prompt, options, None, false)?;
|
||||
|
||||
match choice.as_str() {
|
||||
"[A] Add new item" => {
|
||||
if items.len() >= max_items {
|
||||
eprintln!("⚠ Maximum {} items reached", max_items);
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::execute_fragment(fragment_path, items.len() + 1) {
|
||||
Ok(item_data) => {
|
||||
// Check for duplicates if unique constraint is set
|
||||
if field.unique.unwrap_or(false) {
|
||||
if Self::is_duplicate(&item_data, &items, None, field) {
|
||||
eprintln!("⚠ This item already exists. Duplicates not allowed.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.push(item_data);
|
||||
println!("✓ Item added successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Error adding item: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
"[E] Edit item" => match Self::select_item_to_edit(&items) {
|
||||
Ok(index) => match Self::execute_fragment(fragment_path, index + 1) {
|
||||
Ok(updated_data) => {
|
||||
// Check for duplicates if unique constraint is set (exclude current item)
|
||||
if field.unique.unwrap_or(false) {
|
||||
if Self::is_duplicate(&updated_data, &items, Some(index), field) {
|
||||
eprintln!("⚠ This item already exists. Duplicates not allowed.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items[index] = updated_data;
|
||||
println!("✓ Item updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Error editing item: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("⚠ {}", e);
|
||||
}
|
||||
},
|
||||
"[D] Delete item" => match Self::select_item_to_delete(&items) {
|
||||
Ok(index) => {
|
||||
items.remove(index);
|
||||
println!("✓ Item #{} deleted", index + 1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠ {}", e);
|
||||
}
|
||||
},
|
||||
"[C] Continue to next field" => {
|
||||
if items.len() < min_items {
|
||||
eprintln!("⚠ Minimum {} items required", min_items);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!(items))
|
||||
}
|
||||
|
||||
/// Build menu options for array management
|
||||
fn build_array_menu_options(
|
||||
items: &[HashMap<String, Value>],
|
||||
min_items: usize,
|
||||
max_items: usize,
|
||||
) -> Vec<String> {
|
||||
let mut options = Vec::new();
|
||||
|
||||
if items.len() < max_items {
|
||||
options.push("[A] Add new item".to_string());
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
options.push("[E] Edit item".to_string());
|
||||
options.push("[D] Delete item".to_string());
|
||||
}
|
||||
|
||||
if items.len() >= min_items {
|
||||
options.push("[C] Continue to next field".to_string());
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
/// Execute a fragment to collect data for one array item
|
||||
fn execute_fragment(fragment_path: &str, item_number: usize) -> Result<HashMap<String, Value>> {
|
||||
// Load fragment TOML
|
||||
let fragment_form = crate::form_parser::load_fragment_form(fragment_path)?;
|
||||
|
||||
println!("\n--- Item #{} ---", item_number);
|
||||
|
||||
// Execute each field in fragment
|
||||
let mut results = HashMap::new();
|
||||
let backend = InquireBackend::new();
|
||||
|
||||
for element in &fragment_form.elements {
|
||||
if let crate::form_parser::FormElement::Field(field_def) = element {
|
||||
let value = backend.execute_field_sync(field_def)?;
|
||||
results.insert(field_def.name.clone(), value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Select an item to edit from the list
|
||||
fn select_item_to_edit(items: &[HashMap<String, Value>]) -> Result<usize> {
|
||||
if items.is_empty() {
|
||||
return Err(crate::Error::form_parse_failed("No items to edit"));
|
||||
}
|
||||
|
||||
let labels: Vec<String> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| format!("Item #{} - {}", i + 1, Self::summarize_item(item)))
|
||||
.collect();
|
||||
|
||||
let choice = prompts::select("Select item to edit", labels, None, false)?;
|
||||
|
||||
// Extract index from choice (format: "Item #N - ...")
|
||||
let index_str = choice
|
||||
.split('#')
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('-').next())
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.ok_or_else(|| crate::Error::form_parse_failed("Failed to parse item index"))?;
|
||||
|
||||
Ok(index_str - 1) // Convert to 0-indexed
|
||||
}
|
||||
|
||||
/// Select an item to delete from the list
|
||||
fn select_item_to_delete(items: &[HashMap<String, Value>]) -> Result<usize> {
|
||||
if items.is_empty() {
|
||||
return Err(crate::Error::form_parse_failed("No items to delete"));
|
||||
}
|
||||
|
||||
let labels: Vec<String> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| format!("Item #{} - {}", i + 1, Self::summarize_item(item)))
|
||||
.collect();
|
||||
|
||||
let choice = prompts::select("Select item to delete", labels, None, false)?;
|
||||
|
||||
// Extract index from choice (format: "Item #N - ...")
|
||||
let index_str = choice
|
||||
.split('#')
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('-').next())
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.ok_or_else(|| crate::Error::form_parse_failed("Failed to parse item index"))?;
|
||||
|
||||
Ok(index_str - 1) // Convert to 0-indexed
|
||||
}
|
||||
|
||||
/// Summarize an item for display in lists
|
||||
fn summarize_item(item: &HashMap<String, Value>) -> String {
|
||||
// Try to find a meaningful field to display (first string field, or first field)
|
||||
if let Some(first_string) = item.values().find_map(|v| v.as_str()) {
|
||||
first_string.chars().take(40).collect()
|
||||
} else if let Some(first_value) = item.values().next() {
|
||||
format!("{:?}", first_value).chars().take(40).collect()
|
||||
} else {
|
||||
"(empty)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a new item is a duplicate of existing items
|
||||
fn is_duplicate(
|
||||
new_item: &HashMap<String, Value>,
|
||||
items: &[HashMap<String, Value>],
|
||||
exclude_index: Option<usize>,
|
||||
field: &FieldDefinition,
|
||||
) -> bool {
|
||||
let has_unique = field.unique.unwrap_or(false);
|
||||
|
||||
for (idx, existing_item) in items.iter().enumerate() {
|
||||
// Skip comparing with self when editing
|
||||
if let Some(exclude_idx) = exclude_index {
|
||||
if idx == exclude_idx {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if has_unique {
|
||||
// Check if ALL fields match
|
||||
let all_match = new_item
|
||||
.iter()
|
||||
.all(|(key, value)| existing_item.get(key).map_or(false, |v| v == value));
|
||||
|
||||
if all_match && !new_item.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InquireBackend {
|
||||
@ -151,7 +430,11 @@ impl FormBackend for InquireBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result<Value> {
|
||||
async fn execute_field(
|
||||
&self,
|
||||
field: &FieldDefinition,
|
||||
_context: &RenderContext,
|
||||
) -> Result<Value> {
|
||||
// Wrap synchronous field execution in async context
|
||||
self.execute_field_sync(field)
|
||||
}
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
//! This module provides a trait-based abstraction for different form rendering
|
||||
//! backends (CLI with inquire, TUI with ratatui, Web with axum, etc.).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{DisplayItem, FieldDefinition};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{FieldDefinition, DisplayItem};
|
||||
|
||||
/// Context passed to rendering operations
|
||||
#[derive(Debug, Clone)]
|
||||
@ -32,12 +32,17 @@ pub trait FormBackend: Send + Sync {
|
||||
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>;
|
||||
|
||||
/// Execute a field and return user input (field-by-field mode)
|
||||
async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value>;
|
||||
async fn execute_field(
|
||||
&self,
|
||||
field: &FieldDefinition,
|
||||
context: &RenderContext,
|
||||
) -> Result<Value>;
|
||||
|
||||
/// Execute complete form with all fields at once (complete mode)
|
||||
///
|
||||
/// Takes owned items/fields for mutable rendering operations.
|
||||
/// Form and base_dir references are provided for reactive fragment loading.
|
||||
/// initial_values provides pre-populated field values (e.g., from --defaults).
|
||||
///
|
||||
/// Returns all field values as a map
|
||||
async fn execute_form_complete(
|
||||
@ -46,9 +51,10 @@ pub trait FormBackend: Send + Sync {
|
||||
_base_dir: &Path,
|
||||
items: Vec<DisplayItem>,
|
||||
fields: Vec<FieldDefinition>,
|
||||
initial_values: Option<HashMap<String, Value>>,
|
||||
) -> Result<std::collections::HashMap<String, Value>> {
|
||||
// Default implementation: fall back to field-by-field mode
|
||||
let mut results = std::collections::HashMap::new();
|
||||
let mut results = initial_values.unwrap_or_default();
|
||||
let mut context = RenderContext {
|
||||
results: results.clone(),
|
||||
locale: form.locale.clone(),
|
||||
@ -113,13 +119,9 @@ impl BackendFactory {
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "tui")]
|
||||
BackendType::Tui => {
|
||||
Ok(Box::new(tui::RatatuiBackend::new()))
|
||||
}
|
||||
BackendType::Tui => Ok(Box::new(tui::RatatuiBackend::new())),
|
||||
#[cfg(feature = "web")]
|
||||
BackendType::Web { port } => {
|
||||
Ok(Box::new(web::WebBackend::new(port)))
|
||||
}
|
||||
BackendType::Web { port } => Ok(Box::new(web::WebBackend::new(port))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
@ -70,7 +71,8 @@ fn recompute_form_view(
|
||||
field_buffer: &mut String,
|
||||
) -> crate::error::Result<()> {
|
||||
// Recompute visible items and fields based on current results
|
||||
let (new_items, new_fields) = crate::form_parser::recompute_visible_elements(form, base_dir, results)?;
|
||||
let (new_items, new_fields) =
|
||||
crate::form_parser::recompute_visible_elements(form, base_dir, results)?;
|
||||
|
||||
// Update items and fields
|
||||
*items = new_items;
|
||||
@ -175,6 +177,7 @@ impl FormBackend for RatatuiBackend {
|
||||
FieldType::Custom => self.execute_custom_field(field).await,
|
||||
FieldType::Editor => self.execute_editor_field(field).await,
|
||||
FieldType::Date => self.execute_date_field(field).await,
|
||||
FieldType::RepeatingGroup => self.execute_repeating_group_field(field).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,8 +188,9 @@ impl FormBackend for RatatuiBackend {
|
||||
base_dir: &std::path::Path,
|
||||
mut items: Vec<DisplayItem>,
|
||||
mut fields: Vec<FieldDefinition>,
|
||||
initial_values: Option<std::collections::HashMap<String, Value>>,
|
||||
) -> Result<std::collections::HashMap<String, Value>> {
|
||||
let mut results = std::collections::HashMap::new();
|
||||
let mut results = initial_values.unwrap_or_default();
|
||||
|
||||
// Render display items first
|
||||
for item in &items {
|
||||
@ -283,7 +287,10 @@ impl FormBackend for RatatuiBackend {
|
||||
KeyCode::Up => {
|
||||
// Navigate to previous visible field
|
||||
let visible_indices = get_visible_field_indices(&fields, &results);
|
||||
if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) {
|
||||
if let Some(current_pos) = visible_indices
|
||||
.iter()
|
||||
.position(|&idx| idx == selected_index)
|
||||
{
|
||||
if current_pos > 0 {
|
||||
selected_index = visible_indices[current_pos - 1];
|
||||
load_field_buffer(
|
||||
@ -297,7 +304,10 @@ impl FormBackend for RatatuiBackend {
|
||||
KeyCode::Down => {
|
||||
// Navigate to next visible field
|
||||
let visible_indices = get_visible_field_indices(&fields, &results);
|
||||
if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) {
|
||||
if let Some(current_pos) = visible_indices
|
||||
.iter()
|
||||
.position(|&idx| idx == selected_index)
|
||||
{
|
||||
if current_pos < visible_indices.len() - 1 {
|
||||
selected_index = visible_indices[current_pos + 1];
|
||||
load_field_buffer(
|
||||
@ -309,10 +319,54 @@ impl FormBackend for RatatuiBackend {
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
focus_panel = FormPanel::InputField;
|
||||
// Check if current field is RepeatingGroup
|
||||
if fields[selected_index].field_type == FieldType::RepeatingGroup {
|
||||
// Execute RepeatingGroup handler instead of switching to InputField
|
||||
let field_clone = fields[selected_index].clone();
|
||||
let value =
|
||||
self.execute_repeating_group_field(&field_clone).await?;
|
||||
results.insert(field_clone.name.clone(), value);
|
||||
|
||||
// Reactive re-render
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
// Stay in FieldList panel (will re-render on next loop)
|
||||
} else {
|
||||
focus_panel = FormPanel::InputField;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
focus_panel = FormPanel::InputField;
|
||||
// Check if current field is RepeatingGroup
|
||||
if fields[selected_index].field_type == FieldType::RepeatingGroup {
|
||||
// Execute RepeatingGroup handler instead of switching to InputField
|
||||
let field_clone = fields[selected_index].clone();
|
||||
let value =
|
||||
self.execute_repeating_group_field(&field_clone).await?;
|
||||
results.insert(field_clone.name.clone(), value);
|
||||
|
||||
// Reactive re-render
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
// Stay in FieldList panel
|
||||
} else {
|
||||
focus_panel = FormPanel::InputField;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@ -323,35 +377,38 @@ impl FormBackend for RatatuiBackend {
|
||||
if current_field_type == FieldType::Select
|
||||
|| current_field_type == FieldType::MultiSelect
|
||||
{
|
||||
if let Some(options) = &fields[selected_index].options {
|
||||
if !fields[selected_index].options.is_empty() {
|
||||
let options = &fields[selected_index].options;
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
// Navigate to previous option
|
||||
let current_idx = options
|
||||
.iter()
|
||||
.position(|o| o == &field_buffer)
|
||||
.position(|o| o.value == field_buffer)
|
||||
.unwrap_or(0);
|
||||
if current_idx > 0 {
|
||||
field_buffer = options[current_idx - 1].clone();
|
||||
field_buffer =
|
||||
options[current_idx - 1].value.clone();
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Navigate to next option
|
||||
let current_idx = options
|
||||
.iter()
|
||||
.position(|o| o == &field_buffer)
|
||||
.position(|o| o.value == field_buffer)
|
||||
.unwrap_or(0);
|
||||
if current_idx < options.len() - 1 {
|
||||
field_buffer = options[current_idx + 1].clone();
|
||||
field_buffer =
|
||||
options[current_idx + 1].value.clone();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Search by first letter
|
||||
let search_char = c.to_lowercase().to_string();
|
||||
if let Some(found) = options.iter().find(|o| {
|
||||
o.to_lowercase().starts_with(&search_char)
|
||||
o.value.to_lowercase().starts_with(&search_char)
|
||||
}) {
|
||||
field_buffer = found.clone();
|
||||
field_buffer = found.value.clone();
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
@ -370,7 +427,15 @@ impl FormBackend for RatatuiBackend {
|
||||
);
|
||||
|
||||
// Reactive re-render: update visible items/fields based on new results
|
||||
recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?;
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
focus_panel = FormPanel::FieldList;
|
||||
}
|
||||
@ -408,7 +473,15 @@ impl FormBackend for RatatuiBackend {
|
||||
);
|
||||
|
||||
// Reactive re-render: update visible items/fields based on new results
|
||||
recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?;
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
focus_panel = FormPanel::FieldList;
|
||||
}
|
||||
@ -448,13 +521,18 @@ impl FormBackend for RatatuiBackend {
|
||||
// Save field value as boolean
|
||||
let field = &fields[selected_index];
|
||||
let bool_value = field_buffer == "true";
|
||||
results.insert(
|
||||
field.name.clone(),
|
||||
Value::Bool(bool_value),
|
||||
);
|
||||
results.insert(field.name.clone(), Value::Bool(bool_value));
|
||||
|
||||
// Reactive re-render: update visible items/fields based on new results
|
||||
recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?;
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
focus_panel = FormPanel::FieldList;
|
||||
}
|
||||
@ -491,7 +569,15 @@ impl FormBackend for RatatuiBackend {
|
||||
);
|
||||
|
||||
// Reactive re-render: update visible items/fields based on new results
|
||||
recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?;
|
||||
recompute_form_view(
|
||||
form,
|
||||
base_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
)?;
|
||||
|
||||
focus_panel = FormPanel::FieldList;
|
||||
}
|
||||
@ -795,14 +881,15 @@ impl RatatuiBackend {
|
||||
/// Execute select (single choice) field with pagination support
|
||||
/// Implements pagination and navigation with Up/Down arrows
|
||||
async fn execute_select_field(&self, field: &FieldDefinition) -> Result<Value> {
|
||||
let options = field
|
||||
.options
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::validation_failed("Select field must have options"))?;
|
||||
if field.options.is_empty() {
|
||||
return Err(Error::validation_failed("Select field must have options"));
|
||||
}
|
||||
|
||||
let page_size = field.page_size.unwrap_or(5);
|
||||
let options_strings: Vec<String> =
|
||||
field.options.iter().map(|opt| opt.as_string()).collect();
|
||||
let mut state = SelectState {
|
||||
options: options.clone(),
|
||||
options: options_strings,
|
||||
selected_index: 0,
|
||||
scroll_offset: 0,
|
||||
page_size,
|
||||
@ -917,15 +1004,18 @@ impl RatatuiBackend {
|
||||
/// Execute multiselect field with checkboxes
|
||||
/// Allows selecting multiple options with Space, confirm with Enter
|
||||
async fn execute_multiselect_field(&self, field: &FieldDefinition) -> Result<Value> {
|
||||
let options = field
|
||||
.options
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::validation_failed("MultiSelect field must have options"))?;
|
||||
if field.options.is_empty() {
|
||||
return Err(Error::validation_failed(
|
||||
"MultiSelect field must have options",
|
||||
));
|
||||
}
|
||||
|
||||
let page_size = field.page_size.unwrap_or(5);
|
||||
let options_strings: Vec<String> =
|
||||
field.options.iter().map(|opt| opt.as_string()).collect();
|
||||
let mut state = MultiSelectState {
|
||||
options: options.clone(),
|
||||
selected: vec![false; options.len()],
|
||||
options: options_strings,
|
||||
selected: vec![false; field.options.len()],
|
||||
cursor_index: 0,
|
||||
scroll_offset: 0,
|
||||
page_size,
|
||||
@ -1149,6 +1239,380 @@ impl RatatuiBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute repeating group field with split-pane list/preview UI
|
||||
/// Allows add/edit/delete operations on array items using fragments
|
||||
async fn execute_repeating_group_field(&self, field: &FieldDefinition) -> Result<Value> {
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState},
|
||||
};
|
||||
|
||||
let fragment_path = field
|
||||
.fragment
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::form_parse_failed("RepeatingGroup requires 'fragment' field"))?;
|
||||
|
||||
let min_items = field.min_items.unwrap_or(0);
|
||||
let max_items = field.max_items.unwrap_or(usize::MAX);
|
||||
|
||||
let mut items: Vec<HashMap<String, Value>> = Vec::new();
|
||||
let mut selected_index: usize = 0;
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(0));
|
||||
|
||||
loop {
|
||||
// Render UI in separate scope to ensure terminal_ref is dropped
|
||||
{
|
||||
let mut terminal_ref = self.terminal.write().unwrap();
|
||||
if let Some(terminal) = terminal_ref.as_mut() {
|
||||
let current_items = items.clone();
|
||||
let item_count = current_items.len();
|
||||
let selected_idx = selected_index;
|
||||
let min = min_items;
|
||||
let max = max_items;
|
||||
let field_prompt = field.prompt.clone();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
|
||||
// Split screen: left=list, right=preview
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Left panel: List of items with actions
|
||||
let mut list_items_vec = Vec::new();
|
||||
|
||||
// Add action items
|
||||
if item_count < max {
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("[A] ", Style::default().fg(Color::Green)),
|
||||
Span::raw("Add new item"),
|
||||
])));
|
||||
}
|
||||
|
||||
if !current_items.is_empty() {
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("[E] ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("Edit item"),
|
||||
])));
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("[D] ", Style::default().fg(Color::Red)),
|
||||
Span::raw("Delete item"),
|
||||
])));
|
||||
}
|
||||
|
||||
if item_count >= min {
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![
|
||||
Span::styled("[Enter] ", Style::default().fg(Color::Blue)),
|
||||
Span::raw("Continue to next field"),
|
||||
])));
|
||||
}
|
||||
|
||||
// Add separator
|
||||
list_items_vec.push(ListItem::new(""));
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![Span::styled(
|
||||
"Items:",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)])));
|
||||
|
||||
// Add existing items
|
||||
for (i, item) in current_items.iter().enumerate() {
|
||||
let summary = Self::summarize_item_tui(item);
|
||||
let style = if i == selected_idx && !current_items.is_empty() {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
list_items_vec.push(ListItem::new(Line::from(vec![
|
||||
Span::styled(format!(" #{} ", i + 1), style),
|
||||
Span::styled(summary, style),
|
||||
])));
|
||||
}
|
||||
|
||||
let status_msg = if min > 0 && item_count < min {
|
||||
format!(" - minimum {} required", min)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let list_title =
|
||||
format!("{} ({} items){}", field_prompt, item_count, status_msg);
|
||||
|
||||
let list = List::new(list_items_vec)
|
||||
.block(Block::default().borders(Borders::ALL).title(list_title));
|
||||
|
||||
frame.render_widget(list, chunks[0]);
|
||||
|
||||
// Right panel: Preview of selected item
|
||||
let preview_text = if !current_items.is_empty()
|
||||
&& selected_idx < current_items.len()
|
||||
{
|
||||
let item = ¤t_items[selected_idx];
|
||||
let mut lines = vec![
|
||||
format!("Item #{} Details", selected_idx + 1),
|
||||
"─".repeat(30),
|
||||
];
|
||||
for (key, value) in item {
|
||||
lines.push(format!(
|
||||
"{}: {}",
|
||||
key,
|
||||
Self::format_value_preview(value)
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
} else if current_items.is_empty() {
|
||||
String::from("No items yet\n\nPress 'A' to add an item")
|
||||
} else {
|
||||
String::from("Select an item to preview")
|
||||
};
|
||||
|
||||
let preview = Paragraph::new(preview_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Preview"));
|
||||
|
||||
frame.render_widget(preview, chunks[1]);
|
||||
})
|
||||
.map_err(|e| Error::validation_failed(format!("Render failed: {}", e)))?;
|
||||
}
|
||||
} // terminal_ref dropped here
|
||||
|
||||
// Handle keyboard events
|
||||
if event::poll(Duration::from_millis(100))
|
||||
.map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))?
|
||||
{
|
||||
if let Event::Key(key) = event::read()
|
||||
.map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))?
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
if items.len() >= max_items {
|
||||
self.show_validation_error(&format!(
|
||||
"Maximum {} items reached",
|
||||
max_items
|
||||
))
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute fragment to add new item
|
||||
match self
|
||||
.execute_fragment_for_item(fragment_path, items.len() + 1)
|
||||
.await
|
||||
{
|
||||
Ok(item_data) => {
|
||||
// Check for duplicates if unique constraint is set
|
||||
if field.unique.unwrap_or(false) {
|
||||
if Self::is_duplicate(&item_data, &items, None) {
|
||||
self.show_validation_error(
|
||||
"This item already exists. Duplicates not allowed."
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.push(item_data);
|
||||
selected_index = items.len().saturating_sub(1);
|
||||
}
|
||||
Err(e) if e.is_cancelled() => {
|
||||
// User cancelled, continue loop
|
||||
}
|
||||
Err(e) => {
|
||||
self.show_validation_error(&format!(
|
||||
"Error adding item: {}",
|
||||
e
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||
if items.is_empty() {
|
||||
self.show_validation_error("No items to edit").await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute fragment to edit selected item
|
||||
match self
|
||||
.execute_fragment_for_item(fragment_path, selected_index + 1)
|
||||
.await
|
||||
{
|
||||
Ok(updated_data) => {
|
||||
// Check for duplicates if unique constraint is set (exclude current item)
|
||||
if field.unique.unwrap_or(false) {
|
||||
if Self::is_duplicate(&updated_data, &items, Some(selected_index)) {
|
||||
self.show_validation_error(
|
||||
"This item already exists. Duplicates not allowed."
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items[selected_index] = updated_data;
|
||||
}
|
||||
Err(e) if e.is_cancelled() => {
|
||||
// User cancelled, continue loop
|
||||
}
|
||||
Err(e) => {
|
||||
self.show_validation_error(&format!(
|
||||
"Error editing item: {}",
|
||||
e
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
if items.is_empty() {
|
||||
self.show_validation_error("No items to delete").await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.remove(selected_index);
|
||||
if selected_index >= items.len() && selected_index > 0 {
|
||||
selected_index = items.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !items.is_empty() && selected_index > 0 {
|
||||
selected_index -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if !items.is_empty() && selected_index < items.len() - 1 {
|
||||
selected_index += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if items.len() < min_items {
|
||||
self.show_validation_error(&format!(
|
||||
"Minimum {} items required",
|
||||
min_items
|
||||
))
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
return Ok(json!(items));
|
||||
}
|
||||
KeyCode::Esc => return Err(Error::cancelled()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a fragment to collect data for one array item
|
||||
async fn execute_fragment_for_item(
|
||||
&self,
|
||||
fragment_path: &str,
|
||||
item_number: usize,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
// Load fragment form
|
||||
let fragment_form = crate::form_parser::load_fragment_form(fragment_path)?;
|
||||
|
||||
// Show header in separate scope
|
||||
{
|
||||
let mut terminal_ref = self.terminal.write().unwrap();
|
||||
if let Some(terminal) = terminal_ref.as_mut() {
|
||||
let header_text = format!("--- Item #{} ---", item_number);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
let paragraph = Paragraph::new(header_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Editing Item"));
|
||||
frame.render_widget(paragraph, area);
|
||||
})
|
||||
.map_err(|e| Error::validation_failed(format!("Render failed: {}", e)))?;
|
||||
}
|
||||
} // terminal_ref dropped here
|
||||
|
||||
// Wait for key press to continue
|
||||
loop {
|
||||
if event::poll(Duration::from_millis(100))
|
||||
.map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))?
|
||||
{
|
||||
if let Event::Key(_) = event::read()
|
||||
.map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute each field in fragment
|
||||
let mut results = HashMap::new();
|
||||
for element in &fragment_form.elements {
|
||||
if let crate::form_parser::FormElement::Field(field_def) = element {
|
||||
let context = RenderContext {
|
||||
results: results.clone(),
|
||||
locale: None,
|
||||
};
|
||||
let value = self.execute_field(field_def, &context).await?;
|
||||
results.insert(field_def.name.clone(), value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Summarize an item for display in TUI lists
|
||||
fn summarize_item_tui(item: &HashMap<String, Value>) -> String {
|
||||
if let Some(first_string) = item.values().find_map(|v| v.as_str()) {
|
||||
first_string.chars().take(30).collect()
|
||||
} else if let Some(first_value) = item.values().next() {
|
||||
format!("{:?}", first_value).chars().take(30).collect()
|
||||
} else {
|
||||
"(empty)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a value for preview display
|
||||
fn format_value_preview(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Array(arr) => format!("[{} items]", arr.len()),
|
||||
Value::Object(_) => "{object}".to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a new item is a duplicate of existing items
|
||||
fn is_duplicate(
|
||||
new_item: &HashMap<String, Value>,
|
||||
items: &[HashMap<String, Value>],
|
||||
exclude_index: Option<usize>,
|
||||
) -> bool {
|
||||
for (idx, existing_item) in items.iter().enumerate() {
|
||||
// Skip comparing with self when editing
|
||||
if let Some(exclude_idx) = exclude_index {
|
||||
if idx == exclude_idx {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ALL fields match
|
||||
let all_match = new_item
|
||||
.iter()
|
||||
.all(|(key, value)| existing_item.get(key).map_or(false, |v| v == value));
|
||||
|
||||
if all_match && !new_item.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Display validation error overlay
|
||||
async fn show_validation_error(&self, message: &str) -> Result<()> {
|
||||
let mut terminal_ref = self.terminal.write().unwrap();
|
||||
@ -1509,11 +1973,16 @@ fn render_form_layout(
|
||||
));
|
||||
lines.push("".to_string());
|
||||
|
||||
if let Some(options) = &fields[selected_index].options {
|
||||
if !fields[selected_index].options.is_empty() {
|
||||
let options = &fields[selected_index].options;
|
||||
lines.push(" Options:".to_string());
|
||||
for opt in options.iter().take(4) {
|
||||
let marker = if field_buffer == *opt { "▶" } else { " " };
|
||||
lines.push(format!(" {} {}", marker, opt));
|
||||
let marker = if field_buffer == opt.value {
|
||||
"▶"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
lines.push(format!(" {} {}", marker, opt.display_label()));
|
||||
}
|
||||
if options.len() > 4 {
|
||||
lines.push(format!(" ... and {} more", options.len() - 4));
|
||||
@ -1622,14 +2091,10 @@ fn load_field_buffer(
|
||||
// For Confirm fields, default to "false" if no explicit default
|
||||
buffer.push_str("false");
|
||||
} else if (field.field_type == FieldType::Select || field.field_type == FieldType::MultiSelect)
|
||||
&& field.options.is_some()
|
||||
&& !field.options.is_empty()
|
||||
{
|
||||
// For Select/MultiSelect, default to first option if no explicit default
|
||||
if let Some(options) = &field.options {
|
||||
if !options.is_empty() {
|
||||
buffer.push_str(&options[0]);
|
||||
}
|
||||
}
|
||||
buffer.push_str(&field.options[0].value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1662,15 +2127,11 @@ fn finalize_results(
|
||||
results.insert(field.name.clone(), Value::Bool(false));
|
||||
}
|
||||
FieldType::Select | FieldType::MultiSelect => {
|
||||
if let Some(options) = &field.options {
|
||||
if !options.is_empty() {
|
||||
results.insert(
|
||||
field.name.clone(),
|
||||
Value::String(options[0].clone()),
|
||||
);
|
||||
} else {
|
||||
results.insert(field.name.clone(), Value::String(String::new()));
|
||||
}
|
||||
if !field.options.is_empty() {
|
||||
results.insert(
|
||||
field.name.clone(),
|
||||
Value::String(field.options[0].value.clone()),
|
||||
);
|
||||
} else {
|
||||
results.insert(field.name.clone(), Value::String(String::new()));
|
||||
}
|
||||
@ -1684,7 +2145,10 @@ fn finalize_results(
|
||||
}
|
||||
|
||||
/// Check if a field should be visible based on its conditional
|
||||
fn is_field_visible(field: &FieldDefinition, results: &std::collections::HashMap<String, Value>) -> bool {
|
||||
fn is_field_visible(
|
||||
field: &FieldDefinition,
|
||||
results: &std::collections::HashMap<String, Value>,
|
||||
) -> bool {
|
||||
if let Some(condition) = &field.when {
|
||||
crate::form_parser::evaluate_condition(condition, results)
|
||||
} else {
|
||||
@ -1779,7 +2243,6 @@ impl DateCursor {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Helper to evaluate conditional display logic
|
||||
fn evaluate_condition(
|
||||
_condition: &str,
|
||||
@ -2250,13 +2713,25 @@ mod tests {
|
||||
display_mode: crate::form_parser::DisplayMode::Complete,
|
||||
items: vec![],
|
||||
fields: vec![],
|
||||
elements: vec![],
|
||||
};
|
||||
|
||||
let temp_dir = std::path::Path::new("/tmp");
|
||||
|
||||
// Test with empty vectors - validates recompute_form_view returns success
|
||||
let result = recompute_form_view(&form, temp_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer);
|
||||
assert!(result.is_ok(), "recompute_form_view should handle empty items and fields");
|
||||
let result = recompute_form_view(
|
||||
&form,
|
||||
temp_dir,
|
||||
&results,
|
||||
&mut items,
|
||||
&mut fields,
|
||||
&mut selected_index,
|
||||
&mut field_buffer,
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"recompute_form_view should handle empty items and fields"
|
||||
);
|
||||
|
||||
// Navigation should be reset to 0
|
||||
assert_eq!(selected_index, 0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -29,9 +29,8 @@ fn get_config_path() -> Result<PathBuf> {
|
||||
#[cfg(feature = "i18n")]
|
||||
{
|
||||
use dirs::config_dir;
|
||||
let config_dir = config_dir().ok_or_else(|| {
|
||||
Error::config_not_found("Unable to determine config directory")
|
||||
})?;
|
||||
let config_dir = config_dir()
|
||||
.ok_or_else(|| Error::config_not_found("Unable to determine config directory"))?;
|
||||
Ok(config_dir.join("typedialog").join("config.toml"))
|
||||
}
|
||||
|
||||
|
||||
@ -61,7 +61,10 @@ impl Error {
|
||||
|
||||
/// Create a TOML parse error
|
||||
pub fn toml_parse(source: toml::de::Error) -> Self {
|
||||
Self::new(ErrorKind::TomlParse, format!("TOML parse error: {}", source))
|
||||
Self::new(
|
||||
ErrorKind::TomlParse,
|
||||
format!("TOML parse error: {}", source),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a validation error
|
||||
@ -101,10 +104,7 @@ impl Error {
|
||||
|
||||
/// Check if this is a parse error
|
||||
pub fn is_parse_error(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
ErrorKind::FormParseFailed | ErrorKind::TomlParse
|
||||
)
|
||||
matches!(self.kind, ErrorKind::FormParseFailed | ErrorKind::TomlParse)
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,7 +142,10 @@ impl From<serde_yaml::Error> for Error {
|
||||
|
||||
impl From<chrono::ParseError> for Error {
|
||||
fn from(err: chrono::ParseError) -> Self {
|
||||
Self::new(ErrorKind::ValidationFailed, format!("Date parsing error: {}", err))
|
||||
Self::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Date parsing error: {}", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,19 +22,21 @@ pub fn format_results(
|
||||
) -> crate::error::Result<String> {
|
||||
match format {
|
||||
"json" => {
|
||||
let json_obj = serde_json::to_value(results)
|
||||
.map_err(|e| crate::Error::new(
|
||||
let json_obj = serde_json::to_value(results).map_err(|e| {
|
||||
crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("JSON serialization error: {}", e),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&json_obj)?)
|
||||
}
|
||||
"yaml" => {
|
||||
let yaml_string = serde_yaml::to_string(results)
|
||||
.map_err(|e| crate::Error::new(
|
||||
let yaml_string = serde_yaml::to_string(results).map_err(|e| {
|
||||
crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("YAML serialization error: {}", e),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
Ok(yaml_string)
|
||||
}
|
||||
"text" => {
|
||||
@ -44,6 +46,12 @@ pub fn format_results(
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
"toml" => toml::to_string_pretty(results).map_err(|e| {
|
||||
crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("TOML serialization error: {}", e),
|
||||
)
|
||||
}),
|
||||
_ => Err(crate::Error::new(
|
||||
crate::error::ErrorKind::ValidationFailed,
|
||||
format!("Unknown output format: {}", format),
|
||||
@ -79,11 +87,9 @@ pub fn to_json_value(results: &HashMap<String, Value>) -> Value {
|
||||
|
||||
/// Convert results to JSON string
|
||||
pub fn to_json_string(results: &HashMap<String, Value>) -> crate::error::Result<String> {
|
||||
serde_json::to_string(&to_json_value(results))
|
||||
.map_err(|e| crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("JSON error: {}", e),
|
||||
))
|
||||
serde_json::to_string(&to_json_value(results)).map_err(|e| {
|
||||
crate::Error::new(crate::error::ErrorKind::Other, format!("JSON error: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -72,15 +72,13 @@ impl LocaleLoader {
|
||||
}
|
||||
|
||||
match fs::read_to_string(&toml_file) {
|
||||
Ok(content) => {
|
||||
match toml::from_str::<toml::map::Map<String, toml::Value>>(&content) {
|
||||
Ok(root) => Ok(Self::flatten_toml(root, "")),
|
||||
Err(e) => Err(Error::i18n_failed(format!(
|
||||
"Failed to parse TOML file {:?}: {}",
|
||||
toml_file, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(content) => match toml::from_str::<toml::map::Map<String, toml::Value>>(&content) {
|
||||
Ok(root) => Ok(Self::flatten_toml(root, "")),
|
||||
Err(e) => Err(Error::i18n_failed(format!(
|
||||
"Failed to parse TOML file {:?}: {}",
|
||||
toml_file, e
|
||||
))),
|
||||
},
|
||||
Err(e) => Err(Error::i18n_failed(format!(
|
||||
"Failed to read TOML file {:?}: {}",
|
||||
toml_file, e
|
||||
@ -91,7 +89,10 @@ impl LocaleLoader {
|
||||
/// Flatten nested TOML structure into dot-notation keys
|
||||
///
|
||||
/// Example: `[forms.registration] username = "Name"` → `"forms.registration.username": "Name"`
|
||||
fn flatten_toml(map: toml::map::Map<String, toml::Value>, prefix: &str) -> HashMap<String, String> {
|
||||
fn flatten_toml(
|
||||
map: toml::map::Map<String, toml::Value>,
|
||||
prefix: &str,
|
||||
) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
for (key, value) in map {
|
||||
@ -141,7 +142,10 @@ mod tests {
|
||||
"registration".to_string(),
|
||||
toml::Value::Table({
|
||||
let mut t2 = toml::map::Map::new();
|
||||
t2.insert("title".to_string(), toml::Value::String("Registration".to_string()));
|
||||
t2.insert(
|
||||
"title".to_string(),
|
||||
toml::Value::String("Registration".to_string()),
|
||||
);
|
||||
t2
|
||||
}),
|
||||
);
|
||||
|
||||
@ -47,12 +47,11 @@ impl I18nBundle {
|
||||
let mut bundle = FluentBundle::new(vec![locale.clone()]);
|
||||
|
||||
for resource_str in loader.load_fluent(locale)? {
|
||||
let resource = FluentResource::try_new(resource_str).map_err(|e| {
|
||||
Error::i18n_failed(format!("Fluent parse error: {:?}", e))
|
||||
})?;
|
||||
bundle.add_resource(resource).map_err(|e| {
|
||||
Error::i18n_failed(format!("Bundle add error: {:?}", e))
|
||||
})?;
|
||||
let resource = FluentResource::try_new(resource_str)
|
||||
.map_err(|e| Error::i18n_failed(format!("Fluent parse error: {:?}", e)))?;
|
||||
bundle
|
||||
.add_resource(resource)
|
||||
.map_err(|e| Error::i18n_failed(format!("Bundle add error: {:?}", e)))?;
|
||||
}
|
||||
|
||||
Ok(bundle)
|
||||
@ -66,7 +65,10 @@ impl I18nBundle {
|
||||
if let Some(msg) = self.bundle.get_message(key) {
|
||||
if let Some(pattern) = msg.value() {
|
||||
let mut errors = vec![];
|
||||
return self.bundle.format_pattern(pattern, args, &mut errors).to_string();
|
||||
return self
|
||||
.bundle
|
||||
.format_pattern(pattern, args, &mut errors)
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,7 +81,10 @@ impl I18nBundle {
|
||||
if let Some(msg) = self.fallback_bundle.get_message(key) {
|
||||
if let Some(pattern) = msg.value() {
|
||||
let mut errors = vec![];
|
||||
return self.fallback_bundle.format_pattern(pattern, args, &mut errors).to_string();
|
||||
return self
|
||||
.fallback_bundle
|
||||
.format_pattern(pattern, args, &mut errors)
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,13 +57,13 @@
|
||||
//! typedialog form-to-nickel form.toml results.json -o output.ncl --validate
|
||||
//! ```
|
||||
|
||||
pub mod autocompletion;
|
||||
pub mod backends;
|
||||
pub mod error;
|
||||
pub mod form_parser;
|
||||
pub mod helpers;
|
||||
pub mod prompts;
|
||||
pub mod autocompletion;
|
||||
pub mod nickel;
|
||||
pub mod backends;
|
||||
pub mod prompts;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub mod config;
|
||||
@ -78,11 +78,11 @@ pub mod templates;
|
||||
pub mod cli_common;
|
||||
|
||||
// Re-export main types for convenient access
|
||||
pub use autocompletion::{FilterCompleter, HistoryCompleter, PatternCompleter};
|
||||
pub use backends::{BackendFactory, BackendType, FormBackend, RenderContext};
|
||||
pub use error::{Error, Result};
|
||||
pub use form_parser::{FieldDefinition, FieldType, FormDefinition, DisplayItem};
|
||||
pub use helpers::{format_results, to_json_value, to_json_string};
|
||||
pub use autocompletion::{HistoryCompleter, FilterCompleter, PatternCompleter};
|
||||
pub use backends::{FormBackend, BackendType, BackendFactory, RenderContext};
|
||||
pub use form_parser::{DisplayItem, FieldDefinition, FieldType, FormDefinition};
|
||||
pub use helpers::{format_results, to_json_string, to_json_value};
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub use config::TypeDialogConfig;
|
||||
@ -91,7 +91,7 @@ pub use config::TypeDialogConfig;
|
||||
pub use i18n::{I18nBundle, LocaleResolver};
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
pub use templates::{TemplateEngine, TemplateContextBuilder};
|
||||
pub use templates::{TemplateContextBuilder, TemplateEngine};
|
||||
|
||||
/// Library version
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
274
crates/typedialog-core/src/nickel/alias_generator.rs
Normal file
274
crates/typedialog-core/src/nickel/alias_generator.rs
Normal file
@ -0,0 +1,274 @@
|
||||
//! Alias Generator - Heuristic-based semantic alias generation
|
||||
//!
|
||||
//! Generates human-readable short names (aliases) for Nickel schema field paths
|
||||
//! using configurable heuristic rules.
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ```
|
||||
//! use typedialog_core::nickel::AliasGenerator;
|
||||
//!
|
||||
//! let path = vec!["tracker".to_string(), "core".to_string(), "database".to_string(), "driver".to_string()];
|
||||
//! assert_eq!(AliasGenerator::generate(&path), Some("database_driver".to_string()));
|
||||
//! ```
|
||||
|
||||
/// Generates semantic aliases for Nickel field paths
|
||||
pub struct AliasGenerator;
|
||||
|
||||
impl AliasGenerator {
|
||||
/// Generate an alias for a field path using heuristic rules
|
||||
///
|
||||
/// Returns `None` if the path should use flat_name instead of an alias.
|
||||
///
|
||||
/// # Rules
|
||||
///
|
||||
/// 1. Very short paths (1 element) return None
|
||||
/// 2. Semantic patterns are checked first (features.X.enabled → enable_X)
|
||||
/// 3. Common prefixes (tracker, core, config) are dropped
|
||||
/// 4. Array indicators (plural forms) are singularized
|
||||
/// 5. Path elements are joined with underscore
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - `["tracker", "core", "database", "driver"]` → `"database_driver"`
|
||||
/// - `["features", "prometheus", "enabled"]` → `"enable_prometheus"`
|
||||
/// - `["ssh_credentials", "username"]` → `"ssh_username"`
|
||||
pub fn generate(path: &[String]) -> Option<String> {
|
||||
// Rule 1: Single element paths don't need aliases
|
||||
if path.is_empty() || path.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Rule 2: Check semantic patterns first (on original path)
|
||||
if let Some(alias) = Self::apply_semantic_patterns(path) {
|
||||
return Some(alias);
|
||||
}
|
||||
|
||||
// Rule 3: Drop common prefixes
|
||||
let trimmed = Self::drop_common_prefixes(path);
|
||||
|
||||
// If nothing left after trimming, use last element
|
||||
if trimmed.is_empty() {
|
||||
return path.last().cloned();
|
||||
}
|
||||
|
||||
// Rule 4: Singularize array indicators
|
||||
let singularized = Self::singularize_arrays(&trimmed);
|
||||
|
||||
// Rule 5: Join path elements with underscore
|
||||
Some(singularized.join("_"))
|
||||
}
|
||||
|
||||
/// Drop common prefixes from the beginning of a path
|
||||
fn drop_common_prefixes(path: &[String]) -> Vec<String> {
|
||||
const COMMON_PREFIXES: &[&str] = &["tracker", "core", "config"];
|
||||
|
||||
let mut start_idx = 0;
|
||||
for elem in path.iter() {
|
||||
if COMMON_PREFIXES.contains(&elem.as_str()) {
|
||||
start_idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if start_idx >= path.len() {
|
||||
return path.to_vec();
|
||||
}
|
||||
path[start_idx..].to_vec()
|
||||
}
|
||||
|
||||
/// Singularize plural forms commonly used for arrays
|
||||
fn singularize_arrays(path: &[String]) -> Vec<String> {
|
||||
let singular_map = [
|
||||
("trackers", "tracker"),
|
||||
("servers", "server"),
|
||||
("hosts", "host"),
|
||||
("ports", "port"),
|
||||
("addresses", "address"),
|
||||
("credentials", "credential"),
|
||||
("settings", "setting"),
|
||||
("features", "feature"),
|
||||
("options", "option"),
|
||||
("items", "item"),
|
||||
("tags", "tag"),
|
||||
];
|
||||
|
||||
path.iter()
|
||||
.map(|elem| {
|
||||
for (plural, singular) in &singular_map {
|
||||
if elem == *plural {
|
||||
return singular.to_string();
|
||||
}
|
||||
}
|
||||
elem.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply semantic patterns based on field path structure
|
||||
///
|
||||
/// Returns `Some(alias)` if a semantic pattern matches, `None` otherwise.
|
||||
fn apply_semantic_patterns(path: &[String]) -> Option<String> {
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Pattern: features.X.enabled → enable_X
|
||||
if path.len() >= 2 && path[0] == "features" && path.last() == Some(&"enabled".to_string()) {
|
||||
let feature_name = &path[1];
|
||||
return Some(format!("enable_{}", feature_name));
|
||||
}
|
||||
|
||||
// Pattern: api.X → X (for simple API configs)
|
||||
if path.len() == 2 && path[0] == "api" {
|
||||
return Some(path[1].clone());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_single_element_returns_none() {
|
||||
assert_eq!(AliasGenerator::generate(&vec!["name".to_string()]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_element_simple_path() {
|
||||
// Two-element paths without semantic patterns join with underscore
|
||||
let path = vec!["user".to_string(), "name".to_string()];
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("user_name".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_driver_pattern() {
|
||||
let path = vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"database".to_string(),
|
||||
"driver".to_string(),
|
||||
];
|
||||
// Drops tracker, core prefixes and joins
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("database_driver".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_features_enabled_pattern() {
|
||||
let path = vec![
|
||||
"features".to_string(),
|
||||
"prometheus".to_string(),
|
||||
"enabled".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("enable_prometheus".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_features_grafana_enabled() {
|
||||
let path = vec![
|
||||
"features".to_string(),
|
||||
"grafana".to_string(),
|
||||
"enabled".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("enable_grafana".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_tracker_bind_address() {
|
||||
let path = vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"http".to_string(),
|
||||
"bind_address".to_string(),
|
||||
];
|
||||
// Drops tracker, core and joins
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("http_bind_address".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssh_credentials_username() {
|
||||
let path = vec!["ssh_credentials".to_string(), "username".to_string()];
|
||||
// Two-element path joins with underscore
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("ssh_credentials_username".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_singularize_arrays() {
|
||||
// Longer path with plural array indicator
|
||||
let long_path = vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"http_trackers".to_string(),
|
||||
"bind_address".to_string(),
|
||||
];
|
||||
// After dropping tracker, core: ["http_trackers", "bind_address"]
|
||||
// http_trackers is not singularized (pattern doesn't match exactly)
|
||||
// So result joins to: http_trackers_bind_address
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&long_path),
|
||||
Some("http_trackers_bind_address".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drop_common_prefixes() {
|
||||
let path = vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"config".to_string(),
|
||||
"database".to_string(),
|
||||
"driver".to_string(),
|
||||
];
|
||||
// Should drop tracker, core, config prefixes
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("database_driver".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_pattern() {
|
||||
let path = vec!["api".to_string(), "bind_address".to_string()];
|
||||
// Pattern: api.X → X
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("bind_address".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_three_element_path() {
|
||||
let path = vec![
|
||||
"tracker".to_string(),
|
||||
"http".to_string(),
|
||||
"bind_address".to_string(),
|
||||
];
|
||||
// Drops tracker prefix and joins
|
||||
assert_eq!(
|
||||
AliasGenerator::generate(&path),
|
||||
Some("http_bind_address".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,10 @@
|
||||
//! - `nickel export` - Export evaluated Nickel to JSON
|
||||
//! - `nickel typecheck` - Validate Nickel syntax and types
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Nickel CLI command wrapper
|
||||
pub struct NickelCli;
|
||||
@ -66,10 +66,7 @@ impl NickelCli {
|
||||
/// Returns an error if the nickel command fails or output is invalid JSON.
|
||||
pub fn query(path: &Path, field: Option<&str>) -> Result<serde_json::Value> {
|
||||
let mut cmd = Command::new("nickel");
|
||||
cmd.arg("query")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg(path);
|
||||
cmd.arg("query").arg("--format").arg("json").arg(path);
|
||||
|
||||
if let Some(f) = field {
|
||||
cmd.arg("--field").arg(f);
|
||||
@ -90,11 +87,7 @@ impl NickelCli {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"nickel query failed for {}: {}",
|
||||
path.display(),
|
||||
stderr
|
||||
),
|
||||
format!("nickel query failed for {}: {}", path.display(), stderr),
|
||||
));
|
||||
}
|
||||
|
||||
@ -108,10 +101,7 @@ impl NickelCli {
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to parse nickel query output as JSON: {}",
|
||||
e
|
||||
),
|
||||
format!("Failed to parse nickel query output as JSON: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -151,11 +141,7 @@ impl NickelCli {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"nickel export failed for {}: {}",
|
||||
path.display(),
|
||||
stderr
|
||||
),
|
||||
format!("nickel export failed for {}: {}", path.display(), stderr),
|
||||
));
|
||||
}
|
||||
|
||||
@ -169,10 +155,7 @@ impl NickelCli {
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to parse nickel export output as JSON: {}",
|
||||
e
|
||||
),
|
||||
format!("Failed to parse nickel export output as JSON: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -193,12 +176,12 @@ impl NickelCli {
|
||||
pub fn typecheck(path: &Path) -> Result<()> {
|
||||
// Execute typecheck from the file's directory to allow relative imports to resolve
|
||||
let parent_dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| Error::new(
|
||||
let filename = path.file_name().ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
"Cannot extract filename from path".to_string(),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
let output = Command::new("nickel")
|
||||
.current_dir(parent_dir)
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
//! Extracts validator imports and contract calls from Nickel source files.
|
||||
//! Enables roundtrip workflows where validators are preserved from source → form → output.
|
||||
|
||||
use crate::Result;
|
||||
use super::schema_ir::ContractCall;
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Parser for extracting contract metadata from Nickel source
|
||||
@ -42,7 +42,8 @@ impl ContractParser {
|
||||
|
||||
for line in source.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("let ") && trimmed.contains("import ") && trimmed.contains(" in") {
|
||||
if trimmed.starts_with("let ") && trimmed.contains("import ") && trimmed.contains(" in")
|
||||
{
|
||||
if let Some(eq_idx) = trimmed.find('=') {
|
||||
let name_part = trimmed[4..eq_idx].trim();
|
||||
if let Some(quote_start) = trimmed.find('"') {
|
||||
@ -86,10 +87,23 @@ impl ContractParser {
|
||||
|
||||
// Skip if right_side is a literal value (string, number, bool, object, array)
|
||||
let first_char = right_side.chars().next();
|
||||
if matches!(first_char, Some('"') | Some('[') | Some('{') | Some('-') | Some('0'..='9') | Some('t') | Some('f') | Some('n')) {
|
||||
if matches!(
|
||||
first_char,
|
||||
Some('"')
|
||||
| Some('[')
|
||||
| Some('{')
|
||||
| Some('-')
|
||||
| Some('0'..='9')
|
||||
| Some('t')
|
||||
| Some('f')
|
||||
| Some('n')
|
||||
) {
|
||||
// Could be string, array, object, number, true, false, null
|
||||
// Skip parsing as validator call
|
||||
eprintln!("[DEBUG] Skipping literal for field '{}': '{}'", field_name, right_side);
|
||||
eprintln!(
|
||||
"[DEBUG] Skipping literal for field '{}': '{}'",
|
||||
field_name, right_side
|
||||
);
|
||||
continue;
|
||||
}
|
||||
eprintln!("[DEBUG] Parsing field '{}': '{}'", field_name, right_side);
|
||||
@ -124,8 +138,7 @@ impl ContractParser {
|
||||
let rest = &after_dot[function_end..].trim();
|
||||
|
||||
// Extract args (everything until comma or closing brace)
|
||||
let args_end =
|
||||
rest.find([',', '}']).unwrap_or(rest.len());
|
||||
let args_end = rest.find([',', '}']).unwrap_or(rest.len());
|
||||
let args = rest[..args_end].trim();
|
||||
let args = if args.is_empty() {
|
||||
None
|
||||
@ -137,7 +150,9 @@ impl ContractParser {
|
||||
"{}.{}{}",
|
||||
module,
|
||||
function,
|
||||
args.as_ref().map(|a| format!(" {}", a)).unwrap_or_default()
|
||||
args.as_ref()
|
||||
.map(|a| format!(" {}", a))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
let call = ContractCall {
|
||||
@ -206,7 +221,12 @@ user_config
|
||||
|
||||
let calls = ContractParser::extract_contract_calls(source);
|
||||
|
||||
assert_eq!(calls.len(), 1, "Should only extract 1 contract, but got: {:?}", calls.keys().collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Should only extract 1 contract, but got: {:?}",
|
||||
calls.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(calls.contains_key("env_name"));
|
||||
assert!(!calls.contains_key("db_name"));
|
||||
}
|
||||
@ -229,7 +249,12 @@ user_config
|
||||
let calls = ContractParser::extract_contract_calls(source);
|
||||
|
||||
// Should only extract env_name validator, not literals
|
||||
assert_eq!(calls.len(), 1, "Should only extract 1 contract, but got: {:?}", calls.keys().collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Should only extract 1 contract, but got: {:?}",
|
||||
calls.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(calls.contains_key("env_name"));
|
||||
assert!(!calls.contains_key("db_name"));
|
||||
assert!(!calls.contains_key("enabled"));
|
||||
@ -246,8 +271,14 @@ user_config
|
||||
"#;
|
||||
|
||||
let imports = ContractParser::extract_imports(source);
|
||||
assert_eq!(imports.get("validators").map(|s| s.as_str()), Some("../validators/environment.ncl"));
|
||||
assert_eq!(imports.get("validators_common").map(|s| s.as_str()), Some("../validators/common.ncl"));
|
||||
assert_eq!(
|
||||
imports.get("validators").map(|s| s.as_str()),
|
||||
Some("../validators/environment.ncl")
|
||||
);
|
||||
assert_eq!(
|
||||
imports.get("validators_common").map(|s| s.as_str()),
|
||||
Some("../validators/common.ncl")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -11,10 +11,10 @@
|
||||
//! - `std.number.greater_than N` - Number > N
|
||||
//! - `std.number.less_than N` - Number < N
|
||||
|
||||
use super::schema_ir::{NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
|
||||
/// Validator for Nickel contracts and predicates
|
||||
pub struct ContractValidator;
|
||||
@ -103,7 +103,10 @@ impl ContractValidator {
|
||||
if s.len() < min {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("String must be at least {} characters (std.string.length.min {})", min, min),
|
||||
format!(
|
||||
"String must be at least {} characters (std.string.length.min {})",
|
||||
min, min
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
@ -123,7 +126,10 @@ impl ContractValidator {
|
||||
if s.len() > max {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("String must be at most {} characters (std.string.length.max {})", max, max),
|
||||
format!(
|
||||
"String must be at most {} characters (std.string.length.max {})",
|
||||
max, max
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
@ -176,7 +182,10 @@ impl ContractValidator {
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Number must be greater than {} (std.number.greater_than {})", n, n),
|
||||
format!(
|
||||
"Number must be greater than {} (std.number.greater_than {})",
|
||||
n, n
|
||||
),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
@ -203,7 +212,10 @@ impl ContractValidator {
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Number must be less than {} (std.number.less_than {})", n, n),
|
||||
format!(
|
||||
"Number must be less than {} (std.number.less_than {})",
|
||||
n, n
|
||||
),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
@ -257,10 +269,7 @@ impl ContractAnalyzer {
|
||||
///
|
||||
/// Returns a `when` expression (e.g., "tls_enabled == true") if the field appears to be
|
||||
/// conditionally dependent on another field's value, typically a boolean enable flag.
|
||||
pub fn infer_condition(
|
||||
field: &NickelFieldIR,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Option<String> {
|
||||
pub fn infer_condition(field: &NickelFieldIR, schema: &NickelSchemaIR) -> Option<String> {
|
||||
// Only optional fields might have conditionals
|
||||
if !field.optional {
|
||||
return None;
|
||||
@ -271,10 +280,7 @@ impl ContractAnalyzer {
|
||||
}
|
||||
|
||||
/// Find a boolean field that enables this optional field based on naming convention
|
||||
fn find_boolean_dependency(
|
||||
field: &NickelFieldIR,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Option<String> {
|
||||
fn find_boolean_dependency(field: &NickelFieldIR, schema: &NickelSchemaIR) -> Option<String> {
|
||||
// Extract prefix: "tls_cert_path" -> "tls"
|
||||
let field_prefix = Self::extract_prefix(&field.flat_name)?;
|
||||
|
||||
@ -321,7 +327,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_min_length() {
|
||||
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.min 3");
|
||||
let result =
|
||||
ContractValidator::validate(&json!("hello"), "String | std.string.length.min 3");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.min 3");
|
||||
@ -333,7 +340,8 @@ mod tests {
|
||||
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.max 3");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.max 3");
|
||||
let result =
|
||||
ContractValidator::validate(&json!("hello"), "String | std.string.length.max 3");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@ -375,7 +383,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_unknown_predicate_passes() {
|
||||
let result = ContractValidator::validate(&json!("anything"), "String | some.unknown.predicate");
|
||||
let result =
|
||||
ContractValidator::validate(&json!("anything"), "String | some.unknown.predicate");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
337
crates/typedialog-core/src/nickel/defaults_extractor.rs
Normal file
337
crates/typedialog-core/src/nickel/defaults_extractor.rs
Normal file
@ -0,0 +1,337 @@
|
||||
//! Defaults Extractor - Schema-driven extraction from Nickel JSON exports
|
||||
//!
|
||||
//! Replaces manual extraction functions by using the schema's FieldMapper
|
||||
//! to automatically map JSON paths to field aliases/names.
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ```
|
||||
//! use serde_json::json;
|
||||
//! use typedialog_core::nickel::{DefaultsExtractor, FieldMapper, NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
//! use std::collections::HashMap;
|
||||
//!
|
||||
//! // Create a test schema
|
||||
//! let schema = NickelSchemaIR {
|
||||
//! name: "test".to_string(),
|
||||
//! description: None,
|
||||
//! fields: vec![
|
||||
//! NickelFieldIR {
|
||||
//! path: vec!["ssh_credentials".to_string(), "username".to_string()],
|
||||
//! flat_name: "ssh_credentials-username".to_string(),
|
||||
//! alias: Some("ssh_username".to_string()),
|
||||
//! nickel_type: NickelType::String,
|
||||
//! doc: None,
|
||||
//! default: None,
|
||||
//! optional: false,
|
||||
//! contract: None,
|
||||
//! contract_call: None,
|
||||
//! group: None,
|
||||
//! fragment_marker: None,
|
||||
//! },
|
||||
//! ],
|
||||
//! };
|
||||
//!
|
||||
//! // Create mapper and JSON data
|
||||
//! let mapper = FieldMapper::from_schema(&schema).unwrap();
|
||||
//! let json_data = json!({
|
||||
//! "ssh_credentials": {
|
||||
//! "username": "torrust"
|
||||
//! }
|
||||
//! });
|
||||
//!
|
||||
//! // Extract defaults
|
||||
//! let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
//! assert_eq!(defaults.get("ssh_username"), Some(&json!("torrust")));
|
||||
//! ```
|
||||
|
||||
use super::field_mapper::FieldMapper;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Schema-driven defaults extractor from Nickel JSON exports
|
||||
pub struct DefaultsExtractor;
|
||||
|
||||
impl DefaultsExtractor {
|
||||
/// Extract defaults from Nickel JSON export using schema's FieldMapper
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `nickel_export` - Nested JSON value from Nickel schema execution
|
||||
/// * `mapper` - FieldMapper created from the schema's fields
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// HashMap mapping field names (aliases/flat_names) to their values
|
||||
pub fn extract(nickel_export: &Value, mapper: &FieldMapper) -> HashMap<String, Value> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if let Value::Object(obj) = nickel_export {
|
||||
Self::extract_recursive(obj, Vec::new(), mapper, &mut result);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Recursively traverse nested JSON object, mapping paths to field names
|
||||
fn extract_recursive(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
current_path: Vec<String>,
|
||||
mapper: &FieldMapper,
|
||||
result: &mut HashMap<String, Value>,
|
||||
) {
|
||||
for (key, value) in obj {
|
||||
let mut path = current_path.clone();
|
||||
path.push(key.clone());
|
||||
|
||||
// Try to find this path in the mapper
|
||||
if let Some(field) = mapper.get_by_path(&path) {
|
||||
let field_name = mapper.preferred_name(field);
|
||||
result.insert(field_name.to_string(), value.clone());
|
||||
}
|
||||
|
||||
// Also recurse into nested objects
|
||||
if let Value::Object(nested) = value {
|
||||
Self::extract_recursive(nested, path, mapper, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::nickel::{NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
use serde_json::json;
|
||||
|
||||
fn create_test_schema() -> NickelSchemaIR {
|
||||
NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"database".to_string(),
|
||||
"driver".to_string(),
|
||||
],
|
||||
flat_name: "tracker-core-database-driver".to_string(),
|
||||
alias: Some("database_driver".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"database".to_string(),
|
||||
"database_name".to_string(),
|
||||
],
|
||||
flat_name: "tracker-core-database-database_name".to_string(),
|
||||
alias: Some("sqlite_database_name".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["ssh_credentials".to_string(), "username".to_string()],
|
||||
flat_name: "ssh_credentials-username".to_string(),
|
||||
alias: Some("ssh_username".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["ssh_credentials".to_string(), "port".to_string()],
|
||||
flat_name: "ssh_credentials-port".to_string(),
|
||||
alias: Some("ssh_port".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec![
|
||||
"features".to_string(),
|
||||
"prometheus".to_string(),
|
||||
"enabled".to_string(),
|
||||
],
|
||||
flat_name: "features-prometheus-enabled".to_string(),
|
||||
alias: Some("enable_prometheus".to_string()),
|
||||
nickel_type: NickelType::Bool,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_single_nested_value() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"ssh_credentials": {
|
||||
"username": "torrust"
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
assert_eq!(defaults.get("ssh_username"), Some(&json!("torrust")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_multiple_nested_values() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"ssh_credentials": {
|
||||
"username": "torrust",
|
||||
"port": "22"
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
assert_eq!(defaults.get("ssh_username"), Some(&json!("torrust")));
|
||||
assert_eq!(defaults.get("ssh_port"), Some(&json!("22")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_deeply_nested() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"tracker": {
|
||||
"core": {
|
||||
"database": {
|
||||
"driver": "sqlite3",
|
||||
"database_name": "ttracker.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
assert_eq!(defaults.get("database_driver"), Some(&json!("sqlite3")));
|
||||
assert_eq!(
|
||||
defaults.get("sqlite_database_name"),
|
||||
Some(&json!("ttracker.db"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_boolean_values() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"features": {
|
||||
"prometheus": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
assert_eq!(defaults.get("enable_prometheus"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_unknown_paths_ignored() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"unknown_section": {
|
||||
"unknown_key": "value"
|
||||
},
|
||||
"ssh_credentials": {
|
||||
"username": "torrust"
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
// Only recognized fields should be extracted
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults.get("ssh_username"), Some(&json!("torrust")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mixed_nested_and_unknown() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({
|
||||
"tracker": {
|
||||
"core": {
|
||||
"database": {
|
||||
"driver": "sqlite3",
|
||||
"database_name": "ttracker.db",
|
||||
"unknown_setting": "ignored"
|
||||
},
|
||||
"unknown_section": {
|
||||
"also_ignored": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
// Only known fields extracted
|
||||
assert_eq!(defaults.get("database_driver"), Some(&json!("sqlite3")));
|
||||
assert_eq!(
|
||||
defaults.get("sqlite_database_name"),
|
||||
Some(&json!("ttracker.db"))
|
||||
);
|
||||
assert!(!defaults.contains_key("unknown_setting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_empty_object() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let json_data = json!({});
|
||||
|
||||
let defaults = DefaultsExtractor::extract(&json_data, &mapper);
|
||||
assert!(defaults.is_empty());
|
||||
}
|
||||
}
|
||||
238
crates/typedialog-core/src/nickel/field_mapper.rs
Normal file
238
crates/typedialog-core/src/nickel/field_mapper.rs
Normal file
@ -0,0 +1,238 @@
|
||||
//! Field Mapper - Bidirectional lookup structure for Nickel field names
|
||||
//!
|
||||
//! Provides efficient O(1) lookups across three representations of a field:
|
||||
//! - By alias (semantic short name): "database_driver"
|
||||
//! - By flat_name (full path with separators): "tracker-core-database-driver"
|
||||
//! - By path (vector of nested keys): ["tracker", "core", "database", "driver"]
|
||||
|
||||
use super::schema_ir::{NickelFieldIR, NickelSchemaIR};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Bidirectional field lookup structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FieldMapper {
|
||||
alias_to_field: HashMap<String, NickelFieldIR>,
|
||||
flat_to_field: HashMap<String, NickelFieldIR>,
|
||||
path_to_field: HashMap<Vec<String>, NickelFieldIR>,
|
||||
}
|
||||
|
||||
impl FieldMapper {
|
||||
/// Create a FieldMapper from a Nickel schema
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if alias collision is detected (same alias for different paths)
|
||||
pub fn from_schema(schema: &NickelSchemaIR) -> crate::Result<Self> {
|
||||
let mut alias_to_field: HashMap<String, NickelFieldIR> = HashMap::new();
|
||||
let mut flat_to_field: HashMap<String, NickelFieldIR> = HashMap::new();
|
||||
let mut path_to_field: HashMap<Vec<String>, NickelFieldIR> = HashMap::new();
|
||||
|
||||
for field in &schema.fields {
|
||||
// flat_name is guaranteed unique by separating paths with '-' instead of '_'
|
||||
// This makes flat_name collision impossible (mathematically provable)
|
||||
flat_to_field.insert(field.flat_name.clone(), field.clone());
|
||||
|
||||
// path is unique by definition
|
||||
path_to_field.insert(field.path.clone(), field.clone());
|
||||
|
||||
// alias must be unique, if present
|
||||
if let Some(ref alias) = field.alias {
|
||||
if let Some(existing) = alias_to_field.get(alias) {
|
||||
return Err(crate::error::Error::validation_failed(format!(
|
||||
"Alias collision detected for '{}': used by multiple fields:\n - {:?}\n - {:?}\n\n\
|
||||
Solution: Use flat_names (with '-' separator) instead:\n - {}\n - {}",
|
||||
alias,
|
||||
existing.path,
|
||||
field.path,
|
||||
existing.flat_name,
|
||||
field.flat_name
|
||||
)));
|
||||
}
|
||||
alias_to_field.insert(alias.clone(), field.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
alias_to_field,
|
||||
flat_to_field,
|
||||
path_to_field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get field by any key type (alias, flat_name, or path string)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - Field identifier (can be alias, flat_name, or path joined by '-')
|
||||
pub fn get(&self, key: &str) -> Option<&NickelFieldIR> {
|
||||
// Try alias lookup first (aliases are preferred)
|
||||
if let Some(field) = self.alias_to_field.get(key) {
|
||||
return Some(field);
|
||||
}
|
||||
|
||||
// Try flat_name lookup
|
||||
if let Some(field) = self.flat_to_field.get(key) {
|
||||
return Some(field);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get field by path vector
|
||||
pub fn get_by_path(&self, path: &[String]) -> Option<&NickelFieldIR> {
|
||||
self.path_to_field.get(path)
|
||||
}
|
||||
|
||||
/// Get the preferred name for a field (alias if present, otherwise flat_name)
|
||||
pub fn preferred_name<'a>(&self, field: &'a NickelFieldIR) -> &'a str {
|
||||
field.alias.as_deref().unwrap_or(&field.flat_name)
|
||||
}
|
||||
|
||||
/// Get all fields
|
||||
pub fn fields(&self) -> impl Iterator<Item = &NickelFieldIR> {
|
||||
self.flat_to_field.values()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::nickel::NickelType;
|
||||
|
||||
fn create_test_schema() -> NickelSchemaIR {
|
||||
NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"database".to_string(),
|
||||
"driver".to_string(),
|
||||
],
|
||||
flat_name: "tracker-core-database-driver".to_string(),
|
||||
alias: Some("database_driver".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["ssh_credentials".to_string(), "username".to_string()],
|
||||
flat_name: "ssh_credentials-username".to_string(),
|
||||
alias: Some("ssh_username".to_string()),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["simple".to_string()],
|
||||
flat_name: "simple".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mapper_creation() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
assert_eq!(mapper.flat_to_field.len(), 3);
|
||||
assert_eq!(mapper.alias_to_field.len(), 2);
|
||||
assert_eq!(mapper.path_to_field.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_alias() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let field = mapper.get("database_driver").expect("Field not found");
|
||||
assert_eq!(field.flat_name, "tracker-core-database-driver");
|
||||
assert_eq!(field.alias, Some("database_driver".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_flat_name() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let field = mapper
|
||||
.get("ssh_credentials-username")
|
||||
.expect("Field not found");
|
||||
assert_eq!(
|
||||
field.path,
|
||||
vec!["ssh_credentials".to_string(), "username".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_path() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
|
||||
let path = vec![
|
||||
"tracker".to_string(),
|
||||
"core".to_string(),
|
||||
"database".to_string(),
|
||||
"driver".to_string(),
|
||||
];
|
||||
let field = mapper.get_by_path(&path).expect("Field not found");
|
||||
assert_eq!(field.alias, Some("database_driver".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferred_name_with_alias() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
let field = mapper.get("database_driver").expect("Field not found");
|
||||
|
||||
assert_eq!(mapper.preferred_name(field), "database_driver");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferred_name_without_alias() {
|
||||
let schema = create_test_schema();
|
||||
let mapper = FieldMapper::from_schema(&schema).expect("FieldMapper creation failed");
|
||||
let field = mapper.get("simple").expect("Field not found");
|
||||
|
||||
assert_eq!(mapper.preferred_name(field), "simple");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_collision_with_hyphen_separator() {
|
||||
// This test verifies the core benefit: hyphen separator prevents collisions
|
||||
let path1 = vec!["ssh_credentials".to_string(), "username".to_string()];
|
||||
let path2 = vec!["ssh".to_string(), "credentials_username".to_string()];
|
||||
|
||||
let flat1 = path1.join("-"); // ssh_credentials-username
|
||||
let flat2 = path2.join("-"); // ssh-credentials_username
|
||||
|
||||
assert_ne!(flat1, flat2, "Hyphen separator should prevent collisions");
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,11 @@
|
||||
//! - `doc "English text"` (default locale: en)
|
||||
//! - Additional locales parsed from schema metadata
|
||||
|
||||
use super::schema_ir::NickelSchemaIR;
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use super::schema_ir::NickelSchemaIR;
|
||||
|
||||
/// Extractor for multi-language documentation from Nickel schemas
|
||||
pub struct I18nExtractor;
|
||||
@ -55,14 +55,16 @@ impl I18nExtractor {
|
||||
// Generate .ftl files for each locale
|
||||
for (locale, messages) in &translations {
|
||||
let ftl_dir = output_dir.join(locale);
|
||||
std::fs::create_dir_all(&ftl_dir)
|
||||
.map_err(|e| Error::new(ErrorKind::Io, format!("Failed to create locale dir: {}", e)))?;
|
||||
std::fs::create_dir_all(&ftl_dir).map_err(|e| {
|
||||
Error::new(ErrorKind::Io, format!("Failed to create locale dir: {}", e))
|
||||
})?;
|
||||
|
||||
let ftl_path = ftl_dir.join("forms.ftl");
|
||||
let ftl_content = Self::generate_ftl_content(messages);
|
||||
|
||||
std::fs::write(&ftl_path, ftl_content)
|
||||
.map_err(|e| Error::new(ErrorKind::Io, format!("Failed to write .ftl file: {}", e)))?;
|
||||
std::fs::write(&ftl_path, ftl_content).map_err(|e| {
|
||||
Error::new(ErrorKind::Io, format!("Failed to write .ftl file: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Return mapping of field_name -> i18n_key
|
||||
@ -120,7 +122,6 @@ impl I18nExtractor {
|
||||
fn field_name_to_i18n_key(field_name: &str) -> String {
|
||||
field_name.replace('_', "-")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -129,7 +130,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_field_name_to_i18n_key() {
|
||||
assert_eq!(I18nExtractor::field_name_to_i18n_key("app_name"), "app-name");
|
||||
assert_eq!(
|
||||
I18nExtractor::field_name_to_i18n_key("app_name"),
|
||||
"app-name"
|
||||
);
|
||||
assert_eq!(
|
||||
I18nExtractor::field_name_to_i18n_key("tls_cert_path"),
|
||||
"tls-cert-path"
|
||||
|
||||
@ -27,28 +27,34 @@
|
||||
//! let form = TomlGenerator::generate(&schema_ir)?;
|
||||
//! ```
|
||||
|
||||
pub mod alias_generator;
|
||||
pub mod cli;
|
||||
pub mod schema_ir;
|
||||
pub mod contract_parser;
|
||||
pub mod contracts;
|
||||
pub mod defaults_extractor;
|
||||
pub mod field_mapper;
|
||||
pub mod i18n_extractor;
|
||||
pub mod parser;
|
||||
pub mod toml_generator;
|
||||
pub mod roundtrip;
|
||||
pub mod schema_ir;
|
||||
pub mod serializer;
|
||||
pub mod template_engine;
|
||||
pub mod contracts;
|
||||
pub mod types;
|
||||
pub mod i18n_extractor;
|
||||
pub mod contract_parser;
|
||||
pub mod template_renderer;
|
||||
pub mod roundtrip;
|
||||
pub mod toml_generator;
|
||||
pub mod types;
|
||||
|
||||
pub use alias_generator::AliasGenerator;
|
||||
pub use cli::NickelCli;
|
||||
pub use schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType, ContractCall};
|
||||
pub use contract_parser::{ContractParser, ParsedContracts};
|
||||
pub use contracts::ContractValidator;
|
||||
pub use defaults_extractor::DefaultsExtractor;
|
||||
pub use field_mapper::FieldMapper;
|
||||
pub use i18n_extractor::I18nExtractor;
|
||||
pub use parser::MetadataParser;
|
||||
pub use toml_generator::TomlGenerator;
|
||||
pub use roundtrip::{RoundtripConfig, RoundtripResult};
|
||||
pub use schema_ir::{ContractCall, NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
pub use serializer::NickelSerializer;
|
||||
pub use template_engine::TemplateEngine;
|
||||
pub use contracts::ContractValidator;
|
||||
pub use types::TypeMapper;
|
||||
pub use i18n_extractor::I18nExtractor;
|
||||
pub use contract_parser::{ContractParser, ParsedContracts};
|
||||
pub use template_renderer::NickelTemplateContext;
|
||||
pub use roundtrip::{RoundtripConfig, RoundtripResult};
|
||||
pub use toml_generator::TomlGenerator;
|
||||
pub use types::TypeMapper;
|
||||
|
||||
@ -10,10 +10,10 @@
|
||||
//! - Nested record structures
|
||||
//! - Fragment markers from `# @fragment: name` comments
|
||||
|
||||
use super::schema_ir::{NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
@ -35,12 +35,12 @@ impl MetadataParser {
|
||||
///
|
||||
/// Returns error if JSON structure is invalid or required fields are missing
|
||||
pub fn parse(json: Value) -> Result<NickelSchemaIR> {
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::new(
|
||||
let obj = json.as_object().ok_or_else(|| {
|
||||
Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected JSON object from nickel query",
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
Self::extract_fields(obj, Vec::new(), &mut fields)?;
|
||||
@ -62,11 +62,26 @@ impl MetadataParser {
|
||||
let mut field_path = path.clone();
|
||||
field_path.push(key.clone());
|
||||
|
||||
// For nested objects, recursively extract leaf fields
|
||||
// Check if this is a field definition (has metadata keys) or a nested record
|
||||
if let Value::Object(nested_obj) = value {
|
||||
Self::extract_fields(nested_obj, field_path, fields)?;
|
||||
// If the object has field metadata keys (type, doc, default, etc.),
|
||||
// treat it as a field definition, not a nested structure
|
||||
let has_field_metadata = nested_obj.contains_key("type")
|
||||
|| nested_obj.contains_key("doc")
|
||||
|| nested_obj.contains_key("default")
|
||||
|| nested_obj.contains_key("optional")
|
||||
|| nested_obj.contains_key("contract");
|
||||
|
||||
if has_field_metadata {
|
||||
// This is a field definition - parse it
|
||||
let field = Self::parse_field_value(key, value, field_path)?;
|
||||
fields.push(field);
|
||||
} else {
|
||||
// This is a nested record - recurse into it
|
||||
Self::extract_fields(nested_obj, field_path, fields)?;
|
||||
}
|
||||
} else {
|
||||
// Parse field metadata and type
|
||||
// Non-object values are parsed as fields
|
||||
let field = Self::parse_field_value(key, value, field_path)?;
|
||||
fields.push(field);
|
||||
}
|
||||
@ -76,12 +91,11 @@ impl MetadataParser {
|
||||
}
|
||||
|
||||
/// Parse a single field from its JSON value
|
||||
fn parse_field_value(
|
||||
_name: &str,
|
||||
value: &Value,
|
||||
path: Vec<String>,
|
||||
) -> Result<NickelFieldIR> {
|
||||
let flat_name = path.join("_");
|
||||
fn parse_field_value(_name: &str, value: &Value, path: Vec<String>) -> Result<NickelFieldIR> {
|
||||
// Use hyphen separator for collision-free flat_name
|
||||
let flat_name = path.join("-");
|
||||
// Generate alias using heuristics
|
||||
let alias = super::alias_generator::AliasGenerator::generate(&path);
|
||||
|
||||
// Extract metadata from the value
|
||||
let doc = Self::extract_doc(value);
|
||||
@ -90,17 +104,34 @@ impl MetadataParser {
|
||||
let contract = Self::extract_contract(value);
|
||||
let nickel_type = Self::infer_type(value, &path);
|
||||
|
||||
// Detect array-of-records pattern: Array(Record(...))
|
||||
let (is_array_of_records, array_element_fields) = match &nickel_type {
|
||||
NickelType::Array(elem_type) => {
|
||||
// Check if the element type is a Record
|
||||
if let NickelType::Record(fields) = elem_type.as_ref() {
|
||||
// This is an array of records - extract element field definitions
|
||||
(true, Some(fields.clone()))
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
}
|
||||
_ => (false, None),
|
||||
};
|
||||
|
||||
Ok(NickelFieldIR {
|
||||
path,
|
||||
flat_name,
|
||||
alias,
|
||||
nickel_type,
|
||||
doc,
|
||||
default,
|
||||
optional,
|
||||
contract,
|
||||
contract_call: None, // Will be assigned from contract_parser if present
|
||||
group: None, // Will be assigned during form generation
|
||||
group: None, // Will be assigned during form generation
|
||||
fragment_marker: None, // Will be assigned from source comments if present
|
||||
is_array_of_records,
|
||||
array_element_fields,
|
||||
})
|
||||
}
|
||||
|
||||
@ -202,7 +233,9 @@ impl MetadataParser {
|
||||
///
|
||||
/// Looks for comments like `# @fragment: section_name` before field definitions.
|
||||
/// Returns a map of field_name -> fragment_marker.
|
||||
pub fn extract_fragment_markers_from_source(source_path: &Path) -> Result<HashMap<String, String>> {
|
||||
pub fn extract_fragment_markers_from_source(
|
||||
source_path: &Path,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let source_code = std::fs::read_to_string(source_path)
|
||||
.map_err(|e| Error::new(ErrorKind::Io, format!("Failed to read source file: {}", e)))?;
|
||||
|
||||
@ -236,10 +269,7 @@ impl MetadataParser {
|
||||
if line.starts_with('#') && line.contains("@fragment:") {
|
||||
let start = line.find("@fragment:")?;
|
||||
let after_marker = &line[start + 10..].trim();
|
||||
let fragment_name = after_marker
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.to_string();
|
||||
let fragment_name = after_marker.split_whitespace().next()?.to_string();
|
||||
|
||||
if !fragment_name.is_empty() {
|
||||
return Some(fragment_name);
|
||||
@ -266,10 +296,7 @@ impl MetadataParser {
|
||||
}
|
||||
|
||||
/// Apply fragment markers to parsed schema fields
|
||||
pub fn apply_fragment_markers(
|
||||
schema: &mut NickelSchemaIR,
|
||||
markers: &HashMap<String, String>,
|
||||
) {
|
||||
pub fn apply_fragment_markers(schema: &mut NickelSchemaIR, markers: &HashMap<String, String>) {
|
||||
for field in &mut schema.fields {
|
||||
if let Some(marker) = markers.get(&field.flat_name) {
|
||||
field.fragment_marker = Some(marker.clone());
|
||||
@ -295,11 +322,11 @@ mod tests {
|
||||
let result = MetadataParser::parse(json);
|
||||
assert!(result.is_ok());
|
||||
let schema = result.unwrap();
|
||||
// Flattened: user_name and user_age
|
||||
// Flattened: user-name and user-age (hyphen separator for collision-free names)
|
||||
assert_eq!(schema.fields.len(), 2);
|
||||
let flat_names: Vec<_> = schema.fields.iter().map(|f| f.flat_name.as_str()).collect();
|
||||
assert!(flat_names.contains(&"user_name"));
|
||||
assert!(flat_names.contains(&"user_age"));
|
||||
assert!(flat_names.contains(&"user-name"));
|
||||
assert!(flat_names.contains(&"user-age"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -313,9 +340,9 @@ mod tests {
|
||||
let result = MetadataParser::parse(json);
|
||||
assert!(result.is_ok());
|
||||
let schema = result.unwrap();
|
||||
// Should have settings_email field
|
||||
// Should have settings-email field (hyphen separator)
|
||||
assert_eq!(schema.fields.len(), 1);
|
||||
assert_eq!(schema.fields[0].flat_name, "settings_email");
|
||||
assert_eq!(schema.fields[0].flat_name, "settings-email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -8,16 +8,17 @@
|
||||
//! 5. Render output .ncl with preserved validators
|
||||
//! 6. Validate with `nickel typecheck`
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use crate::Result;
|
||||
use crate::form_parser;
|
||||
use super::contract_parser::ContractParser;
|
||||
use super::template_renderer::NickelTemplateContext;
|
||||
use super::template_engine::TemplateEngine;
|
||||
use super::cli::NickelCli;
|
||||
use super::contract_parser::ContractParser;
|
||||
use super::template_engine::TemplateEngine;
|
||||
use super::template_renderer::NickelTemplateContext;
|
||||
use crate::backends::FormBackend;
|
||||
use crate::form_parser;
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Configuration for a roundtrip workflow
|
||||
pub struct RoundtripConfig {
|
||||
@ -86,10 +87,13 @@ impl RoundtripConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the complete roundtrip workflow
|
||||
pub fn execute(self) -> Result<RoundtripResult> {
|
||||
/// Execute the complete roundtrip workflow with a specific backend
|
||||
pub async fn execute_with_backend(
|
||||
self,
|
||||
backend: &mut dyn FormBackend,
|
||||
) -> Result<RoundtripResult> {
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Starting workflow");
|
||||
eprintln!("[roundtrip] Starting workflow with backend");
|
||||
}
|
||||
|
||||
// Step 1: Read and parse input .ncl
|
||||
@ -97,17 +101,24 @@ impl RoundtripConfig {
|
||||
eprintln!("[roundtrip] Reading input: {}", self.input_ncl.display());
|
||||
}
|
||||
|
||||
let input_source = fs::read_to_string(&self.input_ncl)
|
||||
.map_err(|e| crate::error::Error::new(
|
||||
let input_source = fs::read_to_string(&self.input_ncl).map_err(|e| {
|
||||
crate::error::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("Failed to read input file: {}", e),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
let input_contracts = ContractParser::parse_source(&input_source)?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Found {} imports", input_contracts.imports.len());
|
||||
eprintln!("[roundtrip] Found {} contract calls", input_contracts.field_contracts.len());
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} imports",
|
||||
input_contracts.imports.len()
|
||||
);
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} contract calls",
|
||||
input_contracts.field_contracts.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Execute form to get results
|
||||
@ -115,10 +126,13 @@ impl RoundtripConfig {
|
||||
eprintln!("[roundtrip] Executing form: {}", self.form_path.display());
|
||||
}
|
||||
|
||||
let form_results = Self::execute_form(&self.form_path)?;
|
||||
let form_results = Self::execute_form_with_backend(&self.form_path, backend).await?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Form execution produced {} fields", form_results.len());
|
||||
eprintln!(
|
||||
"[roundtrip] Form execution produced {} fields",
|
||||
form_results.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Render output with contracts or template
|
||||
@ -133,7 +147,7 @@ impl RoundtripConfig {
|
||||
}
|
||||
|
||||
let mut engine = TemplateEngine::new();
|
||||
engine.render_file(template_path, &form_results)?
|
||||
engine.render_file(template_path, &form_results, None)?
|
||||
} else {
|
||||
// Use contract parser method (extract validators from input and apply to values)
|
||||
let ctx = NickelTemplateContext::from_parsed_contracts(
|
||||
@ -144,7 +158,10 @@ impl RoundtripConfig {
|
||||
};
|
||||
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Generated {} bytes of Nickel code", output_nickel.len());
|
||||
eprintln!(
|
||||
"[roundtrip] Generated {} bytes of Nickel code",
|
||||
output_nickel.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
@ -152,11 +169,12 @@ impl RoundtripConfig {
|
||||
eprintln!("[roundtrip] Writing output: {}", self.output_ncl.display());
|
||||
}
|
||||
|
||||
fs::write(&self.output_ncl, &output_nickel)
|
||||
.map_err(|e| crate::error::Error::new(
|
||||
fs::write(&self.output_ncl, &output_nickel).map_err(|e| {
|
||||
crate::error::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("Failed to write output file: {}", e),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
// Step 5: Validate if requested
|
||||
let validation_passed = if self.validate {
|
||||
@ -190,20 +208,165 @@ impl RoundtripConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a form and return results
|
||||
fn execute_form(form_path: &Path) -> Result<HashMap<String, Value>> {
|
||||
/// Execute the complete roundtrip workflow (CLI backend)
|
||||
pub fn execute(self) -> Result<RoundtripResult> {
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Starting workflow");
|
||||
}
|
||||
|
||||
// Step 1: Read and parse input .ncl
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Reading input: {}", self.input_ncl.display());
|
||||
}
|
||||
|
||||
let input_source = fs::read_to_string(&self.input_ncl).map_err(|e| {
|
||||
crate::error::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("Failed to read input file: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let input_contracts = ContractParser::parse_source(&input_source)?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} imports",
|
||||
input_contracts.imports.len()
|
||||
);
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} contract calls",
|
||||
input_contracts.field_contracts.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Execute form to get results
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Executing form: {}", self.form_path.display());
|
||||
}
|
||||
|
||||
let form_results = Self::execute_form(&self.form_path)?;
|
||||
|
||||
if self.verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Form execution produced {} fields",
|
||||
form_results.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Render output with contracts or template
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Rendering output Nickel code");
|
||||
}
|
||||
|
||||
let output_nickel = if let Some(template_path) = &self.template_ncl {
|
||||
// Use Jinja2 template rendering
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Using template: {}", template_path.display());
|
||||
}
|
||||
|
||||
let mut engine = TemplateEngine::new();
|
||||
engine.render_file(template_path, &form_results, None)?
|
||||
} else {
|
||||
// Use contract parser method (extract validators from input and apply to values)
|
||||
let ctx = NickelTemplateContext::from_parsed_contracts(
|
||||
input_contracts.clone(),
|
||||
form_results.clone(),
|
||||
);
|
||||
ctx.render_full_nickel()
|
||||
};
|
||||
|
||||
if self.verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Generated {} bytes of Nickel code",
|
||||
output_nickel.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Writing output: {}", self.output_ncl.display());
|
||||
}
|
||||
|
||||
fs::write(&self.output_ncl, &output_nickel).map_err(|e| {
|
||||
crate::error::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("Failed to write output file: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Step 5: Validate if requested
|
||||
let validation_passed = if self.validate {
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] Validating with nickel typecheck");
|
||||
}
|
||||
|
||||
match NickelCli::typecheck(&self.output_ncl) {
|
||||
Ok(()) => {
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] ✓ Validation passed");
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
Err(e) => {
|
||||
if self.verbose {
|
||||
eprintln!("[roundtrip] ✗ Validation failed: {}", e);
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RoundtripResult {
|
||||
input_contracts,
|
||||
form_results,
|
||||
output_nickel,
|
||||
validation_passed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a form with a specific backend and return results
|
||||
async fn execute_form_with_backend(
|
||||
form_path: &Path,
|
||||
backend: &mut dyn FormBackend,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
// Read form definition
|
||||
let form_content = fs::read_to_string(form_path)
|
||||
.map_err(|e| crate::error::Error::new(
|
||||
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)?;
|
||||
|
||||
// Extract base directory for resolving relative paths (includes, fragments)
|
||||
let base_dir = form_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Execute form using provided backend (TUI, Web, or CLI)
|
||||
form_parser::execute_with_backend_two_phase(form, backend, None, base_dir).await
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
// Extract base directory for resolving relative paths (includes, fragments)
|
||||
let base_dir = form_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Execute form using CLI backend (interactive prompts)
|
||||
form_parser::execute(form)
|
||||
form_parser::execute_with_base_dir(form, base_dir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,9 @@ pub struct NickelFieldIR {
|
||||
/// Flattened name: "user_name" for user.name
|
||||
pub flat_name: String,
|
||||
|
||||
/// Semantic alias for field name (short, human-readable)
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Nickel type information
|
||||
pub nickel_type: NickelType,
|
||||
|
||||
@ -69,6 +72,12 @@ pub struct NickelFieldIR {
|
||||
|
||||
/// Fragment marker from # @fragment: comment
|
||||
pub fragment_marker: Option<String>,
|
||||
|
||||
/// Flag indicating this is Array(Record(...)) pattern
|
||||
pub is_array_of_records: bool,
|
||||
|
||||
/// Fields of the record element type (if is_array_of_records)
|
||||
pub array_element_fields: Option<Vec<NickelFieldIR>>,
|
||||
}
|
||||
|
||||
/// Nickel type information for a field
|
||||
@ -118,11 +127,7 @@ impl NickelSchemaIR {
|
||||
|
||||
/// Get all unique groups in the schema
|
||||
pub fn groups(&self) -> Vec<String> {
|
||||
let mut groups: Vec<_> = self
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| f.group.clone())
|
||||
.collect();
|
||||
let mut groups: Vec<_> = self.fields.iter().filter_map(|f| f.group.clone()).collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
groups
|
||||
|
||||
@ -9,10 +9,10 @@
|
||||
//! - Documentation comments from original schema
|
||||
//! - Pretty printing with proper indentation
|
||||
|
||||
use super::schema_ir::{NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
|
||||
/// Serializer for converting form results to Nickel output
|
||||
pub struct NickelSerializer;
|
||||
@ -28,24 +28,18 @@ impl NickelSerializer {
|
||||
/// # Returns
|
||||
///
|
||||
/// Valid Nickel code as string
|
||||
pub fn serialize(
|
||||
results: &HashMap<String, Value>,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Result<String> {
|
||||
pub fn serialize(results: &HashMap<String, Value>, schema: &NickelSchemaIR) -> Result<String> {
|
||||
// Build nested structure from flat results
|
||||
let nested = Self::unflatten_results(results, schema);
|
||||
|
||||
// Serialize to Nickel with contracts and docs
|
||||
let nickel_string = Self::serialize_value(&nested, schema, 0);
|
||||
let nickel_string = Self::serialize_value(&nested, schema, 0, None);
|
||||
|
||||
Ok(nickel_string)
|
||||
}
|
||||
|
||||
/// Unflatten HashMap results into nested structure
|
||||
fn unflatten_results(
|
||||
results: &HashMap<String, Value>,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Value {
|
||||
fn unflatten_results(results: &HashMap<String, Value>, schema: &NickelSchemaIR) -> Value {
|
||||
let mut root = serde_json::json!({});
|
||||
|
||||
for field in &schema.fields {
|
||||
@ -86,7 +80,18 @@ impl NickelSerializer {
|
||||
}
|
||||
|
||||
/// Serialize a value to Nickel code with type annotations
|
||||
fn serialize_value(value: &Value, schema: &NickelSchemaIR, indent: usize) -> String {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - The JSON value to serialize
|
||||
/// * `schema` - Schema IR for type annotations
|
||||
/// * `indent` - Current indentation level
|
||||
/// * `current_key` - Optional key name for field metadata lookup
|
||||
fn serialize_value(
|
||||
value: &Value,
|
||||
schema: &NickelSchemaIR,
|
||||
indent: usize,
|
||||
current_key: Option<&str>,
|
||||
) -> String {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let indent_str = " ".repeat(indent);
|
||||
@ -108,19 +113,30 @@ impl NickelSerializer {
|
||||
|
||||
// Build field with type annotation and contract
|
||||
let type_annotation = Self::build_type_annotation(&field_meta);
|
||||
let contract = field_meta.contract.as_ref().map(|c| format!(" | {}", c)).unwrap_or_default();
|
||||
let contract = field_meta
|
||||
.contract
|
||||
.as_ref()
|
||||
.map(|c| format!(" | {}", c))
|
||||
.unwrap_or_default();
|
||||
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1);
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1, Some(key));
|
||||
let field_line = if field_value.contains('\n') {
|
||||
format!("{}{} : {}{} = {}", inner_indent_str, key, type_annotation, contract, field_value)
|
||||
format!(
|
||||
"{}{} : {}{} = {}",
|
||||
inner_indent_str, key, type_annotation, contract, field_value
|
||||
)
|
||||
} else {
|
||||
format!("{}{} : {}{} = {},\n", inner_indent_str, key, type_annotation, contract, field_value)
|
||||
format!(
|
||||
"{}{} : {}{} = {},\n",
|
||||
inner_indent_str, key, type_annotation, contract, field_value
|
||||
)
|
||||
};
|
||||
lines.push(field_line);
|
||||
} else {
|
||||
// Fallback without metadata
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1);
|
||||
let field_line = format!("{}{} = {},\n", inner_indent_str, key, field_value);
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1, Some(key));
|
||||
let field_line =
|
||||
format!("{}{} = {},\n", inner_indent_str, key, field_value);
|
||||
lines.push(field_line);
|
||||
}
|
||||
}
|
||||
@ -136,11 +152,35 @@ impl NickelSerializer {
|
||||
return "[]".to_string();
|
||||
}
|
||||
|
||||
// Check if this is an array-of-records (repeating group)
|
||||
let is_array_of_records = current_key
|
||||
.and_then(|key| Self::find_field_for_key(schema, key))
|
||||
.map(|field| field.is_array_of_records)
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut lines = vec!["[\n".to_string()];
|
||||
for item in arr {
|
||||
let item_str = Self::serialize_value(item, schema, indent + 1);
|
||||
lines.push(format!("{}{},\n", inner_indent_str, item_str));
|
||||
|
||||
if is_array_of_records {
|
||||
// Serialize as array of records (from RepeatingGroup)
|
||||
for item in arr {
|
||||
if let Value::Object(item_map) = item {
|
||||
// Serialize record with proper formatting
|
||||
let record_str = Self::serialize_record(item_map, schema, indent + 1);
|
||||
lines.push(format!("{}{},\n", inner_indent_str, record_str));
|
||||
} else {
|
||||
// Fallback for non-object items
|
||||
let item_str = Self::serialize_value(item, schema, indent + 1, None);
|
||||
lines.push(format!("{}{},\n", inner_indent_str, item_str));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple array - serialize items normally
|
||||
for item in arr {
|
||||
let item_str = Self::serialize_value(item, schema, indent + 1, None);
|
||||
lines.push(format!("{}{},\n", inner_indent_str, item_str));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!("{}]", indent_str));
|
||||
lines.join("")
|
||||
}
|
||||
@ -151,6 +191,51 @@ impl NickelSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a record (object) for use in arrays
|
||||
///
|
||||
/// This formats a record on a single line or compact format for array elements
|
||||
fn serialize_record(
|
||||
map: &serde_json::Map<String, Value>,
|
||||
schema: &NickelSchemaIR,
|
||||
indent: usize,
|
||||
) -> String {
|
||||
if map.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
let indent_str = " ".repeat(indent);
|
||||
let mut lines = vec!["{".to_string()];
|
||||
|
||||
let entries: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let value_str = Self::serialize_value(value, schema, indent, None);
|
||||
format!(" {} = {}", key, value_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
lines.push(entries.join(","));
|
||||
lines.push(" }".to_string());
|
||||
|
||||
// Join without newlines for compact record format
|
||||
if lines.join("").len() < 80 {
|
||||
// Single line for short records
|
||||
lines.join("")
|
||||
} else {
|
||||
// Multi-line for long records
|
||||
let inner_indent_str = " ".repeat(indent + 1);
|
||||
let mut result = vec!["{\n".to_string()];
|
||||
|
||||
for (key, value) in map {
|
||||
let value_str = Self::serialize_value(value, schema, indent + 1, None);
|
||||
result.push(format!("{}{} = {},\n", inner_indent_str, key, value_str));
|
||||
}
|
||||
|
||||
result.push(format!("{}}}", indent_str));
|
||||
result.join("")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build type annotation from field metadata
|
||||
fn build_type_annotation(field: &NickelFieldIR) -> String {
|
||||
match &field.nickel_type {
|
||||
@ -182,13 +267,19 @@ impl NickelSerializer {
|
||||
|
||||
/// Find field metadata for a key
|
||||
fn find_field_for_key(schema: &NickelSchemaIR, key: &str) -> Option<NickelFieldIR> {
|
||||
schema.fields.iter().find(|f| f.path.last().is_some_and(|p| p == key)).cloned()
|
||||
schema
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.path.last().is_some_and(|p| p == key))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape string for Nickel double quotes
|
||||
fn escape_string(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -210,6 +301,7 @@ mod tests {
|
||||
NickelFieldIR {
|
||||
path: vec!["name".to_string()],
|
||||
flat_name: "name".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User name".to_string()),
|
||||
default: None,
|
||||
@ -218,10 +310,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["age".to_string()],
|
||||
flat_name: "age".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: None,
|
||||
@ -230,10 +325,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["active".to_string()],
|
||||
flat_name: "active".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Bool,
|
||||
doc: None,
|
||||
default: None,
|
||||
@ -242,6 +340,8 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -272,6 +372,7 @@ mod tests {
|
||||
NickelFieldIR {
|
||||
path: vec!["user".to_string(), "name".to_string()],
|
||||
flat_name: "user_name".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
@ -280,10 +381,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["user".to_string(), "email".to_string()],
|
||||
flat_name: "user_email".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
@ -292,10 +396,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["settings".to_string(), "theme".to_string()],
|
||||
flat_name: "settings_theme".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
@ -304,6 +411,8 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -331,7 +440,8 @@ mod tests {
|
||||
let string_type = NickelSerializer::type_to_nickel(&NickelType::String);
|
||||
assert_eq!(string_type, "String");
|
||||
|
||||
let array_type = NickelSerializer::type_to_nickel(&NickelType::Array(Box::new(NickelType::String)));
|
||||
let array_type =
|
||||
NickelSerializer::type_to_nickel(&NickelType::Array(Box::new(NickelType::String)));
|
||||
assert_eq!(array_type, "[String]");
|
||||
}
|
||||
|
||||
@ -343,20 +453,21 @@ mod tests {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["tags".to_string()],
|
||||
flat_name: "tags".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
},
|
||||
],
|
||||
fields: vec![NickelFieldIR {
|
||||
path: vec!["tags".to_string()],
|
||||
flat_name: "tags".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let output = NickelSerializer::serialize(&results, &schema).unwrap();
|
||||
@ -367,4 +478,52 @@ mod tests {
|
||||
assert!(output.contains("["));
|
||||
assert!(output.contains("]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_array_of_records() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert(
|
||||
"udp_trackers".to_string(),
|
||||
json!([
|
||||
{ "bind_address": "0.0.0.0:6969" },
|
||||
{ "bind_address": "0.0.0.0:6970" }
|
||||
]),
|
||||
);
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![NickelFieldIR {
|
||||
path: vec!["udp_trackers".to_string()],
|
||||
flat_name: "udp_trackers".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::Record(vec![]))),
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: true,
|
||||
array_element_fields: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let output = NickelSerializer::serialize(&results, &schema).unwrap();
|
||||
|
||||
// Verify array structure
|
||||
assert!(output.contains("udp_trackers"));
|
||||
assert!(output.contains("["));
|
||||
assert!(output.contains("]"));
|
||||
|
||||
// Verify records are present
|
||||
assert!(output.contains("bind_address"));
|
||||
assert!(output.contains("0.0.0.0:6969"));
|
||||
assert!(output.contains("0.0.0.0:6970"));
|
||||
|
||||
// Verify record syntax (curly braces for each item)
|
||||
assert!(output.contains("{"));
|
||||
assert!(output.contains("}"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,11 +11,13 @@ use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
use tera::{Tera, Context};
|
||||
use tera::{Context, Tera};
|
||||
|
||||
use super::field_mapper::FieldMapper;
|
||||
|
||||
/// Template engine for rendering .ncl.j2 templates
|
||||
pub struct TemplateEngine {
|
||||
@ -39,19 +41,27 @@ impl TemplateEngine {
|
||||
}
|
||||
|
||||
/// Render a template file with given values
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `template_path` - Path to the template file
|
||||
/// * `values` - HashMap of values to pass to template
|
||||
/// * `mapper` - Optional FieldMapper for dual-key context (aliases + flat_names)
|
||||
pub fn render_file(
|
||||
&mut self,
|
||||
template_path: &Path,
|
||||
values: &HashMap<String, Value>,
|
||||
mapper: Option<&FieldMapper>,
|
||||
) -> Result<String> {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
// Read template file
|
||||
let template_content = fs::read_to_string(template_path)
|
||||
.map_err(|e| Error::new(
|
||||
let template_content = fs::read_to_string(template_path).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Io,
|
||||
format!("Failed to read template file: {}", e),
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
// Add template to engine
|
||||
let template_name = template_path
|
||||
@ -59,24 +69,26 @@ impl TemplateEngine {
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("template");
|
||||
|
||||
self.tera.add_raw_template(template_name, &template_content)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
))?;
|
||||
self.tera
|
||||
.add_raw_template(template_name, &template_content)
|
||||
.map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Build context from values
|
||||
// Build context from values with dual-key support if mapper provided
|
||||
let mut context = Context::new();
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
}
|
||||
self.build_dual_key_context(&mut context, values, mapper)?;
|
||||
|
||||
// Render template
|
||||
self.tera.render(template_name, &context)
|
||||
.map_err(|e| Error::new(
|
||||
self.tera.render(template_name, &context).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to render template: {}", e),
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "templates"))]
|
||||
@ -89,32 +101,41 @@ impl TemplateEngine {
|
||||
}
|
||||
|
||||
/// Render a template string with given values
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `template` - Template content as string
|
||||
/// * `values` - HashMap of values to pass to template
|
||||
/// * `mapper` - Optional FieldMapper for dual-key context (aliases + flat_names)
|
||||
pub fn render_str(
|
||||
&mut self,
|
||||
template: &str,
|
||||
values: &HashMap<String, Value>,
|
||||
mapper: Option<&FieldMapper>,
|
||||
) -> Result<String> {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
// Add template to engine
|
||||
self.tera.add_raw_template("inline", template)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
))?;
|
||||
self.tera
|
||||
.add_raw_template("inline", template)
|
||||
.map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Build context from values
|
||||
// Build context from values with dual-key support if mapper provided
|
||||
let mut context = Context::new();
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
}
|
||||
self.build_dual_key_context(&mut context, values, mapper)?;
|
||||
|
||||
// Render template
|
||||
self.tera.render("inline", &context)
|
||||
.map_err(|e| Error::new(
|
||||
self.tera.render("inline", &context).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to render template: {}", e),
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "templates"))]
|
||||
@ -125,6 +146,44 @@ impl TemplateEngine {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build context with dual-key support (aliases + flat_names)
|
||||
#[cfg(feature = "templates")]
|
||||
fn build_dual_key_context(
|
||||
&self,
|
||||
context: &mut Context,
|
||||
values: &HashMap<String, Value>,
|
||||
mapper: Option<&FieldMapper>,
|
||||
) -> Result<()> {
|
||||
if let Some(m) = mapper {
|
||||
// Dual-key mode: insert under both alias and flat_name if different
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
|
||||
// Also try to find field by key and insert under alternative name
|
||||
if let Some(field) = m.get(key) {
|
||||
if let Some(alias) = &field.alias {
|
||||
if alias != key {
|
||||
// Insert under alias if it's different from the key
|
||||
context.insert(alias, value);
|
||||
}
|
||||
} else {
|
||||
// No alias, try to insert under flat_name if different
|
||||
if &field.flat_name != key {
|
||||
context.insert(&field.flat_name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy mode: direct insertion only
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateEngine {
|
||||
@ -159,7 +218,7 @@ name : String = "{{ name }}"
|
||||
age : Number = {{ age }}
|
||||
"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
let result = engine.render_str(template, &values, None);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Alice"));
|
||||
@ -180,7 +239,7 @@ age : Number = {{ age }}
|
||||
{% endfor %}
|
||||
]"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
let result = engine.render_str(template, &values, None);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("rust"));
|
||||
@ -199,7 +258,7 @@ age : Number = {{ age }}
|
||||
{{ feature }}_enabled : Bool = true
|
||||
{% endif %}"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
let result = engine.render_str(template, &values, None);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("monitoring_enabled"));
|
||||
@ -220,8 +279,14 @@ age : Number = {{ age }}
|
||||
values.insert("ssh_port".to_string(), json!(22));
|
||||
values.insert("database_driver".to_string(), json!("sqlite3"));
|
||||
values.insert("sqlite_database_name".to_string(), json!("tracker.db"));
|
||||
values.insert("udp_tracker_bind_address".to_string(), json!("0.0.0.0:6969"));
|
||||
values.insert("http_tracker_bind_address".to_string(), json!("0.0.0.0:7070"));
|
||||
values.insert(
|
||||
"udp_tracker_bind_address".to_string(),
|
||||
json!("0.0.0.0:6969"),
|
||||
);
|
||||
values.insert(
|
||||
"http_tracker_bind_address".to_string(),
|
||||
json!("0.0.0.0:7070"),
|
||||
);
|
||||
values.insert("http_api_bind_address".to_string(), json!("0.0.0.0:1212"));
|
||||
values.insert("http_api_admin_token".to_string(), json!("secret-token"));
|
||||
values.insert("tracker_private_mode".to_string(), json!(false));
|
||||
@ -229,13 +294,14 @@ age : Number = {{ age }}
|
||||
values.insert("enable_grafana".to_string(), json!(false));
|
||||
|
||||
// Find template file by checking from project root and current directory
|
||||
let template_path = if std::path::Path::new("provisioning/templates/values-template.ncl.j2").exists() {
|
||||
std::path::Path::new("provisioning/templates/values-template.ncl.j2")
|
||||
} else {
|
||||
std::path::Path::new("../../provisioning/templates/values-template.ncl.j2")
|
||||
};
|
||||
let template_path =
|
||||
if std::path::Path::new("provisioning/templates/values-template.ncl.j2").exists() {
|
||||
std::path::Path::new("provisioning/templates/values-template.ncl.j2")
|
||||
} else {
|
||||
std::path::Path::new("../../provisioning/templates/values-template.ncl.j2")
|
||||
};
|
||||
|
||||
let result = engine.render_file(template_path, &values);
|
||||
let result = engine.render_file(template_path, &values, None);
|
||||
|
||||
assert!(result.is_ok(), "Template rendering failed: {:?}", result);
|
||||
let output = result.unwrap();
|
||||
@ -248,9 +314,15 @@ age : Number = {{ age }}
|
||||
assert!(output.contains("driver = \"sqlite3\""));
|
||||
assert!(output.contains("database_name = \"tracker.db\""));
|
||||
assert!(output.contains("private = false"));
|
||||
assert!(output.contains("bind_address = validators_network.ValidBindAddress \"0.0.0.0:6969\""));
|
||||
assert!(
|
||||
output.contains("bind_address = validators_network.ValidBindAddress \"0.0.0.0:6969\"")
|
||||
);
|
||||
|
||||
// Verify it's long enough to contain the full template
|
||||
assert!(output.len() > 2000, "Output too short: {} bytes", output.len());
|
||||
assert!(
|
||||
output.len() > 2000,
|
||||
"Output too short: {} bytes",
|
||||
output.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
//! Renders Nickel output using Tera templates (.ncl.j2).
|
||||
//! Preserves validators and structure from parsed contracts.
|
||||
|
||||
use super::schema_ir::ContractCall;
|
||||
use super::contract_parser::ParsedContracts;
|
||||
use std::collections::HashMap;
|
||||
use super::schema_ir::ContractCall;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Context for rendering Nickel templates
|
||||
#[derive(Debug, Clone)]
|
||||
@ -26,10 +26,7 @@ pub struct NickelTemplateContext {
|
||||
|
||||
impl NickelTemplateContext {
|
||||
/// Create context from parsed contracts and form results
|
||||
pub fn from_parsed_contracts(
|
||||
parsed: ParsedContracts,
|
||||
results: HashMap<String, Value>,
|
||||
) -> Self {
|
||||
pub fn from_parsed_contracts(parsed: ParsedContracts, results: HashMap<String, Value>) -> Self {
|
||||
Self {
|
||||
imports: parsed.imports,
|
||||
contracts: parsed.field_contracts,
|
||||
@ -78,7 +75,11 @@ impl NickelTemplateContext {
|
||||
)
|
||||
} else {
|
||||
// Fallback to direct value rendering
|
||||
format!(" {} = {},\n", field_name, Self::value_to_nickel_expr(value))
|
||||
format!(
|
||||
" {} = {},\n",
|
||||
field_name,
|
||||
Self::value_to_nickel_expr(value)
|
||||
)
|
||||
};
|
||||
|
||||
output.push_str(&line);
|
||||
@ -101,10 +102,7 @@ impl NickelTemplateContext {
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Array(arr) => {
|
||||
let items: Vec<String> = arr
|
||||
.iter()
|
||||
.map(Self::value_to_nickel_expr)
|
||||
.collect();
|
||||
let items: Vec<String> = arr.iter().map(Self::value_to_nickel_expr).collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
@ -144,15 +142,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_value_to_nickel_arg() {
|
||||
assert_eq!(NickelTemplateContext::value_to_nickel_arg(&json!("test")), "\"test\"");
|
||||
assert_eq!(
|
||||
NickelTemplateContext::value_to_nickel_arg(&json!("test")),
|
||||
"\"test\""
|
||||
);
|
||||
assert_eq!(NickelTemplateContext::value_to_nickel_arg(&json!(42)), "42");
|
||||
assert_eq!(NickelTemplateContext::value_to_nickel_arg(&json!(true)), "true");
|
||||
assert_eq!(
|
||||
NickelTemplateContext::value_to_nickel_arg(&json!(true)),
|
||||
"true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_to_nickel_expr() {
|
||||
assert_eq!(NickelTemplateContext::value_to_nickel_expr(&json!("test")), "\"test\"");
|
||||
assert_eq!(NickelTemplateContext::value_to_nickel_expr(&json!([1, 2, 3])), "[1, 2, 3]");
|
||||
assert_eq!(
|
||||
NickelTemplateContext::value_to_nickel_expr(&json!("test")),
|
||||
"\"test\""
|
||||
);
|
||||
assert_eq!(
|
||||
NickelTemplateContext::value_to_nickel_expr(&json!([1, 2, 3])),
|
||||
"[1, 2, 3]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -172,7 +182,8 @@ mod tests {
|
||||
};
|
||||
|
||||
// Key by field name, not module.function
|
||||
ctx.contracts.insert("ssh_port".to_string(), contract.clone());
|
||||
ctx.contracts
|
||||
.insert("ssh_port".to_string(), contract.clone());
|
||||
|
||||
let result = ctx.render_with_contract(&json!(8080), &contract);
|
||||
assert!(result.contains("validators.ValidPort"));
|
||||
@ -205,7 +216,8 @@ mod tests {
|
||||
|
||||
// Key by field name - this is the fix!
|
||||
ctx.contracts.insert("ssh_port".to_string(), port_contract);
|
||||
ctx.contracts.insert("environment_name".to_string(), env_contract);
|
||||
ctx.contracts
|
||||
.insert("environment_name".to_string(), env_contract);
|
||||
|
||||
// Verify each field gets its own validator
|
||||
let port_result = ctx.find_contract_for_field("ssh_port");
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
//! Handles type mapping, metadata extraction, flatten/unflatten operations,
|
||||
//! semantic grouping, and conditional expression inference from contracts.
|
||||
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
use super::contracts::ContractAnalyzer;
|
||||
use crate::form_parser::{FormDefinition, FieldDefinition, FieldType, DisplayItem};
|
||||
use super::schema_ir::{NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{DisplayItem, FieldDefinition, FieldType, FormDefinition, SelectOption};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generator for converting Nickel schemas to typedialog TOML forms
|
||||
@ -53,7 +53,12 @@ impl TomlGenerator {
|
||||
// Generate display items for groups (headers)
|
||||
let mut item_order = 0;
|
||||
if use_groups {
|
||||
for group in &schema.fields.iter().filter_map(|f| f.group.as_ref()).collect::<std::collections::HashSet<_>>() {
|
||||
for group in &schema
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| f.group.as_ref())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
{
|
||||
items.push(DisplayItem {
|
||||
name: format!("{}_header", group),
|
||||
item_type: "section".to_string(),
|
||||
@ -87,12 +92,8 @@ impl TomlGenerator {
|
||||
// Second pass: generate fields
|
||||
let mut field_order = item_order + 100; // Offset to allow items to display first
|
||||
for field in &schema.fields {
|
||||
let form_field = Self::field_ir_to_definition(
|
||||
field,
|
||||
flatten_records,
|
||||
field_order,
|
||||
schema,
|
||||
)?;
|
||||
let form_field =
|
||||
Self::field_ir_to_definition(field, flatten_records, field_order, schema)?;
|
||||
fields.push(form_field);
|
||||
field_order += 1;
|
||||
}
|
||||
@ -102,6 +103,7 @@ impl TomlGenerator {
|
||||
description: schema.description.clone(),
|
||||
fields,
|
||||
items,
|
||||
elements: Vec::new(),
|
||||
locale: None,
|
||||
template: None,
|
||||
output_template: None,
|
||||
@ -138,9 +140,35 @@ impl TomlGenerator {
|
||||
// Add ungrouped fields to main form
|
||||
if !ungrouped_fields.is_empty() {
|
||||
for field in ungrouped_fields {
|
||||
let form_field = Self::field_ir_to_definition(field, flatten_records, field_order, schema)?;
|
||||
main_fields.push(form_field);
|
||||
field_order += 1;
|
||||
// Check if this is an array-of-records field
|
||||
if field.is_array_of_records {
|
||||
// Generate fragment for array element
|
||||
let fragment_name = format!("{}_item", field.flat_name);
|
||||
|
||||
if let Some(element_fields) = &field.array_element_fields {
|
||||
let fragment_form = Self::create_fragment_from_fields(
|
||||
&fragment_name,
|
||||
element_fields,
|
||||
flatten_records,
|
||||
schema,
|
||||
)?;
|
||||
|
||||
result.insert(fragment_name.clone(), fragment_form);
|
||||
|
||||
// Generate RepeatingGroup field in main form
|
||||
let repeating_field =
|
||||
Self::create_repeating_group_field(field, &fragment_name, field_order)?;
|
||||
|
||||
main_fields.push(repeating_field);
|
||||
field_order += 1;
|
||||
}
|
||||
} else {
|
||||
// Normal field
|
||||
let form_field =
|
||||
Self::field_ir_to_definition(field, flatten_records, field_order, schema)?;
|
||||
main_fields.push(form_field);
|
||||
field_order += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +209,7 @@ impl TomlGenerator {
|
||||
description: schema.description.clone(),
|
||||
fields: main_fields,
|
||||
items: main_items,
|
||||
elements: Vec::new(),
|
||||
locale: None,
|
||||
template: None,
|
||||
output_template: None,
|
||||
@ -197,7 +226,8 @@ impl TomlGenerator {
|
||||
let mut fields = Vec::new();
|
||||
|
||||
for (field_order, field) in fragment_fields.into_iter().enumerate() {
|
||||
let form_field = Self::field_ir_to_definition(field, flatten_records, field_order, schema)?;
|
||||
let form_field =
|
||||
Self::field_ir_to_definition(field, flatten_records, field_order, schema)?;
|
||||
fields.push(form_field);
|
||||
}
|
||||
|
||||
@ -206,6 +236,7 @@ impl TomlGenerator {
|
||||
description: Some(format!("Fragment: {}", fragment)),
|
||||
fields,
|
||||
items: Vec::new(),
|
||||
elements: Vec::new(),
|
||||
locale: None,
|
||||
template: None,
|
||||
output_template: None,
|
||||
@ -233,14 +264,12 @@ impl TomlGenerator {
|
||||
.clone()
|
||||
.unwrap_or_else(|| format_prompt_from_path(&field.flat_name));
|
||||
|
||||
let default = field.default.as_ref().map(|v| {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
let default = field.default.as_ref().map(|v| match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
|
||||
let options = match &field.nickel_type {
|
||||
@ -248,7 +277,7 @@ impl TomlGenerator {
|
||||
// Try to extract enum options from array element type or doc
|
||||
Self::extract_enum_options(field)
|
||||
}
|
||||
_ => None,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Determine if field is required
|
||||
@ -262,7 +291,11 @@ impl TomlGenerator {
|
||||
let when_condition = ContractAnalyzer::infer_condition(field, schema);
|
||||
|
||||
Ok(FieldDefinition {
|
||||
name: field.flat_name.clone(),
|
||||
// Use alias if present (semantic name), otherwise use flat_name
|
||||
name: field
|
||||
.alias
|
||||
.clone()
|
||||
.unwrap_or_else(|| field.flat_name.clone()),
|
||||
field_type,
|
||||
prompt,
|
||||
default,
|
||||
@ -284,6 +317,13 @@ impl TomlGenerator {
|
||||
nickel_contract: field.contract.clone(),
|
||||
nickel_path: Some(field.path.clone()),
|
||||
nickel_doc: field.doc.clone(),
|
||||
nickel_alias: field.alias.clone(),
|
||||
fragment: None,
|
||||
min_items: None,
|
||||
max_items: None,
|
||||
default_items: None,
|
||||
unique: None,
|
||||
unique_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -293,10 +333,16 @@ impl TomlGenerator {
|
||||
NickelType::String => Ok((FieldType::Text, None)),
|
||||
NickelType::Number => Ok((FieldType::Custom, Some("f64".to_string()))),
|
||||
NickelType::Bool => Ok((FieldType::Confirm, None)),
|
||||
NickelType::Array(_) => {
|
||||
// Default to editor for JSON array editing; can be changed to select/multiselect
|
||||
// if options are detected
|
||||
Ok((FieldType::Editor, Some("json".to_string())))
|
||||
NickelType::Array(elem_type) => {
|
||||
// Check if this is an array of records (repeating group)
|
||||
if matches!(elem_type.as_ref(), NickelType::Record(_)) {
|
||||
// Array of records -> use RepeatingGroup
|
||||
Ok((FieldType::RepeatingGroup, None))
|
||||
} else {
|
||||
// Simple arrays -> use Editor with JSON for now
|
||||
// (could be enhanced to MultiSelect if options are detected)
|
||||
Ok((FieldType::Editor, Some("json".to_string())))
|
||||
}
|
||||
}
|
||||
NickelType::Record(_) => {
|
||||
// Records are handled by nested field generation
|
||||
@ -310,24 +356,111 @@ impl TomlGenerator {
|
||||
}
|
||||
|
||||
/// Extract enum options from field documentation or array structure
|
||||
fn extract_enum_options(field: &NickelFieldIR) -> Option<Vec<String>> {
|
||||
/// Returns Vec of SelectOption (with value only, no labels)
|
||||
fn extract_enum_options(field: &NickelFieldIR) -> Vec<SelectOption> {
|
||||
// Check if doc contains "Options: X, Y, Z" pattern
|
||||
if let Some(doc) = &field.doc {
|
||||
if let Some(start) = doc.find("Options:") {
|
||||
let options_str = &doc[start + 8..]; // Skip "Options:"
|
||||
let options: Vec<String> = options_str
|
||||
let options: Vec<SelectOption> = options_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| SelectOption {
|
||||
value: s.trim().to_string(),
|
||||
label: None,
|
||||
})
|
||||
.filter(|opt| !opt.value.is_empty())
|
||||
.collect();
|
||||
if !options.is_empty() {
|
||||
return Some(options);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, don't try to extract from array structure unless we have more info
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Create a FormDefinition fragment from array element fields
|
||||
fn create_fragment_from_fields(
|
||||
name: &str,
|
||||
element_fields: &[NickelFieldIR],
|
||||
flatten_records: bool,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Result<FormDefinition> {
|
||||
let mut fields = Vec::new();
|
||||
|
||||
// Generate FieldDefinition for each element field
|
||||
for (order, elem_field) in element_fields.iter().enumerate() {
|
||||
let field_def =
|
||||
Self::field_ir_to_definition(elem_field, flatten_records, order, schema)?;
|
||||
fields.push(field_def);
|
||||
}
|
||||
|
||||
Ok(FormDefinition {
|
||||
name: name.to_string(),
|
||||
description: Some(format!("Array element definition for {}", name)),
|
||||
fields,
|
||||
items: Vec::new(),
|
||||
elements: Vec::new(),
|
||||
locale: None,
|
||||
template: None,
|
||||
output_template: None,
|
||||
i18n_prefix: None,
|
||||
display_mode: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a RepeatingGroup FieldDefinition pointing to a fragment
|
||||
fn create_repeating_group_field(
|
||||
field: &NickelFieldIR,
|
||||
fragment_name: &str,
|
||||
order: usize,
|
||||
) -> Result<FieldDefinition> {
|
||||
let prompt = field
|
||||
.doc
|
||||
.as_ref()
|
||||
.map(|d| d.lines().next().unwrap_or("").to_string())
|
||||
.unwrap_or_else(|| {
|
||||
field
|
||||
.alias
|
||||
.clone()
|
||||
.unwrap_or_else(|| field.flat_name.clone())
|
||||
});
|
||||
|
||||
Ok(FieldDefinition {
|
||||
name: field
|
||||
.alias
|
||||
.clone()
|
||||
.unwrap_or_else(|| field.flat_name.clone()),
|
||||
field_type: FieldType::RepeatingGroup,
|
||||
prompt,
|
||||
default: None,
|
||||
placeholder: None,
|
||||
options: Vec::new(),
|
||||
required: Some(!field.optional),
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
page_size: None,
|
||||
vim_mode: None,
|
||||
custom_type: None,
|
||||
min_date: None,
|
||||
max_date: None,
|
||||
week_start: None,
|
||||
order,
|
||||
when: None,
|
||||
i18n: None,
|
||||
group: field.group.clone(),
|
||||
nickel_contract: field.contract.clone(),
|
||||
nickel_path: Some(field.path.clone()),
|
||||
nickel_doc: field.doc.clone(),
|
||||
nickel_alias: field.alias.clone(),
|
||||
fragment: Some(format!("fragments/{}.toml", fragment_name)),
|
||||
min_items: if field.optional { Some(0) } else { Some(1) },
|
||||
max_items: Some(10), // Default limit
|
||||
default_items: Some(if field.optional { 0 } else { 1 }),
|
||||
unique: None,
|
||||
unique_key: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,6 +510,7 @@ mod tests {
|
||||
NickelFieldIR {
|
||||
path: vec!["name".to_string()],
|
||||
flat_name: "name".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User full name".to_string()),
|
||||
default: Some(json!("Alice")),
|
||||
@ -385,10 +519,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["age".to_string()],
|
||||
flat_name: "age".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Number,
|
||||
doc: Some("User age".to_string()),
|
||||
default: None,
|
||||
@ -397,6 +534,8 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -409,7 +548,10 @@ mod tests {
|
||||
assert_eq!(form.fields[0].name, "name");
|
||||
assert_eq!(form.fields[0].field_type, FieldType::Text);
|
||||
assert_eq!(form.fields[0].required, Some(true));
|
||||
assert_eq!(form.fields[0].nickel_contract, Some("String | std.string.NonEmpty".to_string()));
|
||||
assert_eq!(
|
||||
form.fields[0].nickel_contract,
|
||||
Some("String | std.string.NonEmpty".to_string())
|
||||
);
|
||||
|
||||
// Check second field
|
||||
assert_eq!(form.fields[1].name, "age");
|
||||
@ -427,6 +569,7 @@ mod tests {
|
||||
NickelFieldIR {
|
||||
path: vec!["user_name".to_string()],
|
||||
flat_name: "user_name".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User name".to_string()),
|
||||
default: None,
|
||||
@ -435,10 +578,13 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: Some("user".to_string()),
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["settings_theme".to_string()],
|
||||
flat_name: "settings_theme".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Theme preference".to_string()),
|
||||
default: Some(json!("dark")),
|
||||
@ -447,6 +593,8 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: Some("settings".to_string()),
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -498,6 +646,7 @@ mod tests {
|
||||
let field = NickelFieldIR {
|
||||
path: vec!["status".to_string()],
|
||||
flat_name: "status".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: Some("Status. Options: pending, active, completed".to_string()),
|
||||
default: None,
|
||||
@ -506,17 +655,19 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
};
|
||||
|
||||
let options = TomlGenerator::extract_enum_options(&field);
|
||||
assert_eq!(
|
||||
options,
|
||||
Some(vec![
|
||||
"pending".to_string(),
|
||||
"active".to_string(),
|
||||
"completed".to_string(),
|
||||
])
|
||||
);
|
||||
assert_eq!(options.len(), 3);
|
||||
assert_eq!(options[0].value, "pending");
|
||||
assert_eq!(options[1].value, "active");
|
||||
assert_eq!(options[2].value, "completed");
|
||||
// Labels should be None for options extracted from doc strings
|
||||
assert_eq!(options[0].label, None);
|
||||
assert_eq!(options[1].label, None);
|
||||
assert_eq!(options[2].label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -524,6 +675,7 @@ mod tests {
|
||||
let field = NickelFieldIR {
|
||||
path: vec!["count".to_string()],
|
||||
flat_name: "count".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: Some(json!(42)),
|
||||
@ -532,6 +684,8 @@ mod tests {
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
};
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
@ -543,4 +697,96 @@ mod tests {
|
||||
let form_field = TomlGenerator::field_ir_to_definition(&field, false, 0, &schema).unwrap();
|
||||
assert_eq!(form_field.default, Some("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_of_records_detection_and_fragment_generation() {
|
||||
// Create a field with Array(Record(...)) type
|
||||
let tracker_field = NickelFieldIR {
|
||||
path: vec!["bind_address".to_string()],
|
||||
flat_name: "bind_address".to_string(),
|
||||
alias: None,
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Bind Address".to_string()),
|
||||
default: Some(json!("0.0.0.0:6969")),
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
};
|
||||
|
||||
let udp_trackers_field = NickelFieldIR {
|
||||
path: vec!["udp_trackers".to_string()],
|
||||
flat_name: "udp_trackers".to_string(),
|
||||
alias: Some("trackers".to_string()),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::Record(vec![
|
||||
tracker_field.clone()
|
||||
]))),
|
||||
doc: Some("UDP Tracker Listeners".to_string()),
|
||||
default: None,
|
||||
optional: true,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: true,
|
||||
array_element_fields: Some(vec![tracker_field.clone()]),
|
||||
};
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "tracker_config".to_string(),
|
||||
description: Some("Torrust Tracker Configuration".to_string()),
|
||||
fields: vec![udp_trackers_field.clone()],
|
||||
};
|
||||
|
||||
// Test fragment generation
|
||||
let forms = TomlGenerator::generate_with_fragments(&schema, true, false).unwrap();
|
||||
|
||||
// Should have main form + fragment form
|
||||
assert!(forms.contains_key("main"));
|
||||
assert!(
|
||||
forms.contains_key("udp_trackers_item"),
|
||||
"Should generate fragment for array element"
|
||||
);
|
||||
|
||||
// Check main form has RepeatingGroup field
|
||||
let main_form = forms.get("main").unwrap();
|
||||
assert_eq!(main_form.fields.len(), 1);
|
||||
assert_eq!(main_form.fields[0].field_type, FieldType::RepeatingGroup);
|
||||
assert_eq!(main_form.fields[0].name, "trackers"); // Uses alias
|
||||
assert_eq!(
|
||||
main_form.fields[0].fragment,
|
||||
Some("fragments/udp_trackers_item.toml".to_string())
|
||||
);
|
||||
assert_eq!(main_form.fields[0].min_items, Some(0)); // Optional
|
||||
assert_eq!(main_form.fields[0].max_items, Some(10));
|
||||
assert_eq!(main_form.fields[0].default_items, Some(0));
|
||||
|
||||
// Check fragment form has element fields
|
||||
let fragment_form = forms.get("udp_trackers_item").unwrap();
|
||||
assert_eq!(fragment_form.fields.len(), 1);
|
||||
assert_eq!(fragment_form.fields[0].name, "bind_address");
|
||||
assert_eq!(fragment_form.fields[0].field_type, FieldType::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nickel_type_array_of_records_maps_to_repeating_group() {
|
||||
// Test that Array(Record(...)) maps to RepeatingGroup
|
||||
let record_type = NickelType::Record(vec![]);
|
||||
let array_of_records = NickelType::Array(Box::new(record_type));
|
||||
|
||||
let (field_type, custom_type) =
|
||||
TomlGenerator::nickel_type_to_field_type(&array_of_records).unwrap();
|
||||
assert_eq!(field_type, FieldType::RepeatingGroup);
|
||||
assert_eq!(custom_type, None);
|
||||
|
||||
// Test that simple arrays still map to Editor
|
||||
let simple_array = NickelType::Array(Box::new(NickelType::String));
|
||||
let (field_type, custom_type) =
|
||||
TomlGenerator::nickel_type_to_field_type(&simple_array).unwrap();
|
||||
assert_eq!(field_type, FieldType::Editor);
|
||||
assert_eq!(custom_type, Some("json".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use chrono::{NaiveDate, Weekday};
|
||||
use inquire::{Confirm, DateSelect, Editor as InquireEditor, MultiSelect, Password, PasswordDisplayMode, Select, Text};
|
||||
use inquire::{
|
||||
Confirm, DateSelect, Editor as InquireEditor, MultiSelect, Password, PasswordDisplayMode,
|
||||
Select, Text,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
@ -68,11 +71,10 @@ pub fn confirm(prompt: &str, default: Option<bool>, _formatter: Option<&str>) ->
|
||||
|
||||
// Apply default formatter based on inquire's standard
|
||||
// Custom formatter from CLI is handled in main.rs after getting the boolean result
|
||||
confirm_prompt = confirm_prompt
|
||||
.with_formatter(&|ans| match ans {
|
||||
true => "yes".to_owned(),
|
||||
false => "no".to_owned(),
|
||||
});
|
||||
confirm_prompt = confirm_prompt.with_formatter(&|ans| match ans {
|
||||
true => "yes".to_owned(),
|
||||
false => "no".to_owned(),
|
||||
});
|
||||
|
||||
match confirm_prompt.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
@ -98,8 +100,7 @@ pub fn confirm(prompt: &str, default: Option<bool>, _formatter: Option<&str>) ->
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn password(prompt: &str, with_toggle: bool) -> Result<String> {
|
||||
let mut password_prompt = Password::new(prompt)
|
||||
.with_display_mode(PasswordDisplayMode::Masked);
|
||||
let mut password_prompt = Password::new(prompt).with_display_mode(PasswordDisplayMode::Masked);
|
||||
|
||||
if with_toggle {
|
||||
password_prompt = password_prompt
|
||||
@ -476,7 +477,11 @@ fn stdin_multi_select(prompt: &str) -> Result<Vec<String>> {
|
||||
Ok(selections)
|
||||
}
|
||||
|
||||
fn stdin_editor(prompt: &str, prefix_text: Option<&str>, file_extension: Option<&str>) -> Result<String> {
|
||||
fn stdin_editor(
|
||||
prompt: &str,
|
||||
prefix_text: Option<&str>,
|
||||
file_extension: Option<&str>,
|
||||
) -> Result<String> {
|
||||
println!("{}", prompt);
|
||||
|
||||
// Try to open a temporary file with the editor
|
||||
@ -562,8 +567,7 @@ fn open_editor_with_temp_file(
|
||||
}
|
||||
|
||||
// Read the edited content back
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(Error::io)?;
|
||||
let content = std::fs::read_to_string(&path).map_err(Error::io)?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
@ -24,9 +24,8 @@ impl TemplateEngine {
|
||||
let pattern = path.join("**/*.tera");
|
||||
let pattern_str = pattern.to_str().unwrap_or("templates/**/*.tera");
|
||||
|
||||
Tera::new(pattern_str).map_err(|e| {
|
||||
Error::template_failed(format!("Failed to initialize Tera: {}", e))
|
||||
})?
|
||||
Tera::new(pattern_str)
|
||||
.map_err(|e| Error::template_failed(format!("Failed to initialize Tera: {}", e)))?
|
||||
} else {
|
||||
Tera::default()
|
||||
};
|
||||
@ -47,7 +46,10 @@ impl TemplateEngine {
|
||||
/// Render a template by name with context
|
||||
pub fn render(&self, template_name: &str, context: &tera::Context) -> Result<String> {
|
||||
self.tera.render(template_name, context).map_err(|e| {
|
||||
Error::template_failed(format!("Failed to render template '{}': {}", template_name, e))
|
||||
Error::template_failed(format!(
|
||||
"Failed to render template '{}': {}",
|
||||
template_name, e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -104,9 +106,7 @@ mod tests {
|
||||
results.insert("username".to_string(), json!("bob"));
|
||||
results.insert("email".to_string(), json!("bob@example.com"));
|
||||
|
||||
let context = TemplateContextBuilder::new()
|
||||
.with_results(&results)
|
||||
.build();
|
||||
let context = TemplateContextBuilder::new().with_results(&results).build();
|
||||
|
||||
let template = "User: {{ username }}, Email: {{ email }}";
|
||||
let result = engine.render_str(template, &context);
|
||||
|
||||
1043
crates/typedialog-core/tests/nickel_integration.rs
Normal file
1043
crates/typedialog-core/tests/nickel_integration.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@ clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
||||
@ -4,16 +4,19 @@
|
||||
//! Uses ratatui for advanced terminal rendering capabilities.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use typedialog_core::{form_parser, Error, Result};
|
||||
use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::nickel::RoundtripConfig;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::nickel::{
|
||||
DefaultsExtractor, FieldMapper, I18nExtractor, MetadataParser, NickelCli, NickelFieldIR,
|
||||
NickelSchemaIR, TemplateEngine, TomlGenerator,
|
||||
};
|
||||
use typedialog_core::{form_parser, Error, Result};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
@ -55,6 +58,67 @@ enum Commands {
|
||||
defaults: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Convert Nickel schema to TOML form
|
||||
#[command(name = "nickel-to-form")]
|
||||
NickelToForm {
|
||||
/// Path to Nickel schema file (.ncl)
|
||||
schema: PathBuf,
|
||||
|
||||
/// Optional path to current data file (.ncl or .json) for defaults
|
||||
#[arg(value_name = "CURRENT_DATA")]
|
||||
current_data: Option<PathBuf>,
|
||||
|
||||
/// Flatten nested records into flat field names
|
||||
#[arg(long)]
|
||||
flatten: bool,
|
||||
|
||||
/// Use semantic grouping for form organization
|
||||
#[arg(long)]
|
||||
groups: bool,
|
||||
|
||||
/// Generate fragments for large schemas based on @fragment markers
|
||||
#[arg(long)]
|
||||
fragments: bool,
|
||||
|
||||
/// Generate conditionals from optional fields and boolean dependencies
|
||||
#[arg(long)]
|
||||
conditionals: bool,
|
||||
|
||||
/// Extract i18n translations from doc comments
|
||||
#[arg(long)]
|
||||
i18n: bool,
|
||||
|
||||
/// Output directory for generated forms (default: stdout for single file)
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Convert form results to Nickel output
|
||||
#[command(name = "form-to-nickel")]
|
||||
FormToNickel {
|
||||
/// Path to TOML form definition
|
||||
form: PathBuf,
|
||||
|
||||
/// Path to results JSON file OR Nickel template (.ncl.j2)
|
||||
/// - If .json: Read pre-computed results (3-step workflow)
|
||||
/// - If .ncl.j2: Execute form + render template (2-step workflow)
|
||||
input: PathBuf,
|
||||
|
||||
/// Validate output with nickel typecheck
|
||||
#[arg(long)]
|
||||
validate: bool,
|
||||
},
|
||||
|
||||
/// Render Nickel template with form results
|
||||
#[command(name = "nickel-template")]
|
||||
NickelTemplate {
|
||||
/// Path to Nickel template file (.ncl.j2)
|
||||
template: PathBuf,
|
||||
|
||||
/// Path to results JSON file
|
||||
results: PathBuf,
|
||||
},
|
||||
|
||||
/// Complete roundtrip: .ncl → form → .ncl with preserved validators
|
||||
#[command(name = "nickel-roundtrip")]
|
||||
NickelRoundtrip {
|
||||
@ -82,6 +146,88 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
/// Recursively flatten nested JSON objects into a single-level map
|
||||
/// Converts {"a": {"b": {"c": "value"}}} to {"a_b_c": "value"}
|
||||
fn flatten_json_object(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut result = HashMap::new();
|
||||
flatten_recursive(obj, "", &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn flatten_recursive(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
prefix: &str,
|
||||
result: &mut HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
for (key, value) in obj.iter() {
|
||||
let new_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}_{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
serde_json::Value::Object(nested) => {
|
||||
// Recursively flatten nested objects
|
||||
flatten_recursive(nested, &new_key, result);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
// For arrays, just store them as-is with their key
|
||||
result.insert(new_key, serde_json::Value::Array(arr.clone()));
|
||||
}
|
||||
_ => {
|
||||
// Keep primitive values (string, number, bool, null)
|
||||
result.insert(new_key, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract defaults from Nickel export using schema-driven approach
|
||||
fn extract_nickel_defaults(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
form_fields: &[form_parser::FieldDefinition],
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
// Build a minimal schema from form fields that have nickel_path
|
||||
let mut schema_fields = Vec::new();
|
||||
for field in form_fields {
|
||||
if let Some(nickel_path) = &field.nickel_path {
|
||||
schema_fields.push(NickelFieldIR {
|
||||
path: nickel_path.clone(),
|
||||
flat_name: nickel_path.join("-"),
|
||||
alias: field.nickel_alias.clone(),
|
||||
nickel_type: typedialog_core::nickel::NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have schema fields, use DefaultsExtractor
|
||||
if !schema_fields.is_empty() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "form".to_string(),
|
||||
description: None,
|
||||
fields: schema_fields,
|
||||
};
|
||||
if let Ok(mapper) = FieldMapper::from_schema(&schema) {
|
||||
return DefaultsExtractor::extract(&serde_json::Value::Object(obj.clone()), &mapper);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: flatten everything if schema-driven extraction fails
|
||||
flatten_json_object(obj)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
@ -90,6 +236,37 @@ async fn main() -> Result<()> {
|
||||
Some(Commands::Form { config, defaults }) => {
|
||||
execute_form(config, defaults, &args.format, &args.out, &args.locale).await?;
|
||||
}
|
||||
Some(Commands::NickelToForm {
|
||||
schema,
|
||||
current_data,
|
||||
flatten,
|
||||
groups,
|
||||
fragments,
|
||||
conditionals,
|
||||
i18n,
|
||||
output,
|
||||
}) => {
|
||||
nickel_to_form_cmd(
|
||||
schema,
|
||||
current_data,
|
||||
flatten,
|
||||
groups,
|
||||
fragments,
|
||||
conditionals,
|
||||
i18n,
|
||||
output,
|
||||
)?;
|
||||
}
|
||||
Some(Commands::FormToNickel {
|
||||
form,
|
||||
input,
|
||||
validate,
|
||||
}) => {
|
||||
form_to_nickel_cmd(form, input, &args.out, validate)?;
|
||||
}
|
||||
Some(Commands::NickelTemplate { template, results }) => {
|
||||
nickel_template_cmd(template, results, &args.out)?;
|
||||
}
|
||||
Some(Commands::NickelRoundtrip {
|
||||
input,
|
||||
form,
|
||||
@ -101,7 +278,9 @@ async fn main() -> Result<()> {
|
||||
nickel_roundtrip_cmd(input, form, output, ncl_template, !no_validate, verbose).await?;
|
||||
}
|
||||
None => {
|
||||
let config = args.config.ok_or_else(|| Error::validation_failed("Please provide a form configuration file"))?;
|
||||
let config = args.config.ok_or_else(|| {
|
||||
Error::validation_failed("Please provide a form configuration file")
|
||||
})?;
|
||||
execute_form(config, None, &args.format, &args.out, &args.locale).await?;
|
||||
}
|
||||
}
|
||||
@ -109,23 +288,92 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
async fn execute_form(
|
||||
config: PathBuf,
|
||||
defaults: Option<PathBuf>,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
cli_locale: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config).map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
let mut form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// TUI backend uses unified elements array internally, migrate if using legacy format
|
||||
form.migrate_to_elements();
|
||||
|
||||
// Extract base directory for resolving relative paths in includes
|
||||
let base_dir = config
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let base_dir = config.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
|
||||
// Load default values from JSON file if provided
|
||||
// Note: expand_includes() is handled internally by build_element_list()
|
||||
|
||||
// Load default values from JSON or .ncl file if provided
|
||||
let initial_values = if let Some(defaults_path) = defaults {
|
||||
let defaults_content = fs::read_to_string(&defaults_path)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to read defaults file: {}", e)))?;
|
||||
let defaults_json: HashMap<String, serde_json::Value> = serde_json::from_str(&defaults_content)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to parse defaults JSON: {}", e)))?;
|
||||
use typedialog_core::nickel::NickelCli;
|
||||
|
||||
let is_ncl = defaults_path.extension().and_then(|s| s.to_str()) == Some("ncl");
|
||||
|
||||
let defaults_json: HashMap<String, serde_json::Value> = if is_ncl {
|
||||
// Convert .ncl to JSON using nickel export
|
||||
NickelCli::verify()?;
|
||||
let value = NickelCli::export(&defaults_path)?;
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
// Use schema-driven extraction with form fields, fallback to flattening
|
||||
let extracted = extract_nickel_defaults(&map, &form.fields);
|
||||
let flattened = flatten_json_object(&map);
|
||||
let mut combined = extracted;
|
||||
// Flattened values fill in gaps not covered by extraction
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults .ncl must export to a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Read JSON directly - combine extraction and flatten
|
||||
let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to read defaults file: {}", e))
|
||||
})?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&defaults_content).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to parse defaults JSON: {}", e))
|
||||
})?;
|
||||
match parsed {
|
||||
serde_json::Value::Object(map) => {
|
||||
let extracted = extract_nickel_defaults(&map, &form.fields);
|
||||
let flattened = flatten_json_object(&map);
|
||||
let mut combined = extracted;
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults must be a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !defaults_json.is_empty() {
|
||||
eprintln!(
|
||||
"[DEBUG] Loaded {} default field values",
|
||||
defaults_json.len()
|
||||
);
|
||||
for key in defaults_json.keys().take(5) {
|
||||
eprintln!("[DEBUG] - {}", key);
|
||||
}
|
||||
if defaults_json.len() > 5 {
|
||||
eprintln!("[DEBUG] ... and {} more", defaults_json.len() - 5);
|
||||
}
|
||||
}
|
||||
Some(defaults_json)
|
||||
} else {
|
||||
None
|
||||
@ -137,7 +385,8 @@ async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, format: &str,
|
||||
let resolver = LocaleResolver::new(config.clone());
|
||||
let form_locale = form.locale.as_deref();
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
let fallback_locale: LanguageIdentifier = "en-US"
|
||||
.parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
let loader = LocaleLoader::new(config.locales_path);
|
||||
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
|
||||
@ -147,9 +396,23 @@ async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, format: &str,
|
||||
|
||||
let mut backend = BackendFactory::create(BackendType::Tui)?;
|
||||
let results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), Some(bundle), base_dir, initial_values).await?
|
||||
form_parser::execute_with_backend_i18n_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
Some(bundle),
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), None, base_dir, initial_values).await?
|
||||
form_parser::execute_with_backend_i18n_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
None,
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
print_results(&results, format, output_file)?;
|
||||
@ -172,25 +435,196 @@ fn print_results(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn nickel_to_form_cmd(
|
||||
schema: PathBuf,
|
||||
_current_data: Option<PathBuf>,
|
||||
flatten: bool,
|
||||
groups: bool,
|
||||
fragments: bool,
|
||||
_conditionals: bool, // Conditionals are auto-generated by ContractAnalyzer
|
||||
i18n: bool,
|
||||
output_dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
// Verify nickel CLI is available
|
||||
NickelCli::verify()?;
|
||||
|
||||
// Extract metadata from schema
|
||||
let metadata = NickelCli::query(schema.as_path(), Some("inputs"))?;
|
||||
|
||||
// Parse into intermediate representation
|
||||
let mut schema_ir = MetadataParser::parse(metadata)?;
|
||||
|
||||
// Step 1: Extract fragment markers from schema source file (if enabled)
|
||||
if fragments {
|
||||
let markers = MetadataParser::extract_fragment_markers_from_source(schema.as_path())?;
|
||||
MetadataParser::apply_fragment_markers(&mut schema_ir, &markers);
|
||||
}
|
||||
|
||||
// Step 2: Generate TOML form(s)
|
||||
let forms_output = if fragments && schema_ir.fields.iter().any(|f| f.fragment_marker.is_some())
|
||||
{
|
||||
// Multi-file output: main form + fragments
|
||||
TomlGenerator::generate_with_fragments(&schema_ir, flatten, groups)?
|
||||
} else {
|
||||
// Single file output
|
||||
let form_def = TomlGenerator::generate(&schema_ir, flatten, groups)?;
|
||||
let mut single_output = HashMap::new();
|
||||
single_output.insert("form.toml".to_string(), form_def);
|
||||
single_output
|
||||
};
|
||||
|
||||
// Determine output directory
|
||||
let output_path = output_dir.unwrap_or_else(|| {
|
||||
if fragments && forms_output.len() > 1 {
|
||||
PathBuf::from("generated")
|
||||
} else {
|
||||
PathBuf::from(".")
|
||||
}
|
||||
});
|
||||
|
||||
// Step 3: Write form files
|
||||
if forms_output.len() == 1 && output_path.as_path() == std::path::Path::new(".") {
|
||||
// Single file to stdout or specified path
|
||||
if let Some((_, form_def)) = forms_output.iter().next() {
|
||||
let toml_output = ::toml::to_string_pretty(form_def)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
println!("{}", toml_output);
|
||||
}
|
||||
} else {
|
||||
// Write multiple files or to directory
|
||||
fs::create_dir_all(&output_path).map_err(Error::io)?;
|
||||
|
||||
for (filename, form_def) in forms_output {
|
||||
let file_path = if filename.starts_with("fragments/") {
|
||||
output_path
|
||||
.join("fragments")
|
||||
.join(filename.strip_prefix("fragments/").unwrap())
|
||||
} else {
|
||||
output_path.join(&filename)
|
||||
};
|
||||
|
||||
fs::create_dir_all(file_path.parent().unwrap()).map_err(Error::io)?;
|
||||
|
||||
let toml_output = ::toml::to_string_pretty(&form_def)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
|
||||
fs::write(&file_path, &toml_output).map_err(Error::io)?;
|
||||
eprintln!(" Generated: {}", file_path.display());
|
||||
}
|
||||
|
||||
println!("✓ Forms generated in {}/", output_path.display());
|
||||
}
|
||||
|
||||
// Step 4: Extract i18n translations (if enabled)
|
||||
if i18n {
|
||||
let i18n_output_dir = output_path.join("locales");
|
||||
let _i18n_mapping = I18nExtractor::extract_and_generate(&schema_ir, &i18n_output_dir)?;
|
||||
|
||||
eprintln!(
|
||||
"✓ i18n translations generated in {}/",
|
||||
i18n_output_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn form_to_nickel_cmd(
|
||||
form: PathBuf,
|
||||
input: PathBuf,
|
||||
output: &Option<PathBuf>,
|
||||
_validate: bool,
|
||||
) -> Result<()> {
|
||||
let form_content = fs::read_to_string(&form).map_err(Error::io)?;
|
||||
let _form_def = form_parser::parse_toml(&form_content)?;
|
||||
|
||||
// Determine input type based on extension
|
||||
let results: HashMap<String, serde_json::Value> = if input.extension().and_then(|s| s.to_str())
|
||||
== Some("ncl.j2")
|
||||
{
|
||||
// Template: would require executing form and rendering template
|
||||
// For now, return error as this requires interactive execution
|
||||
return Err(Error::validation_failed(
|
||||
"Template-based form-to-nickel requires interactive execution. Use .json input instead."
|
||||
));
|
||||
} else if input.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
// Load pre-computed results from JSON
|
||||
let json_content = fs::read_to_string(&input).map_err(Error::io)?;
|
||||
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?
|
||||
} else {
|
||||
return Err(Error::validation_failed(
|
||||
"Input file must be .json or .ncl.j2",
|
||||
));
|
||||
};
|
||||
|
||||
// For now, provide a placeholder message as full Nickel serialization requires schema
|
||||
let nickel_output = format!(
|
||||
"# Form results (JSON format for now)\n{}",
|
||||
serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?
|
||||
);
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
fs::write(path, &nickel_output).map_err(Error::io)?;
|
||||
println!("Nickel output written to {}", path.display());
|
||||
} else {
|
||||
println!("{}", nickel_output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nickel_template_cmd(
|
||||
template: PathBuf,
|
||||
results: PathBuf,
|
||||
output: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
// Load results JSON file
|
||||
let json_content = fs::read_to_string(&results).map_err(Error::io)?;
|
||||
let values: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
|
||||
// Load and render template
|
||||
let mut engine = TemplateEngine::new();
|
||||
let nickel_output = engine.render_file(template.as_path(), &values, None)?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
fs::write(path, &nickel_output).map_err(Error::io)?;
|
||||
println!("Template rendered to {}", path.display());
|
||||
} else {
|
||||
println!("{}", nickel_output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nickel_roundtrip_cmd(
|
||||
input: PathBuf,
|
||||
form: PathBuf,
|
||||
form_path: PathBuf,
|
||||
output: PathBuf,
|
||||
ncl_template: Option<PathBuf>,
|
||||
validate: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
use typedialog_core::nickel::RoundtripConfig;
|
||||
|
||||
if verbose {
|
||||
eprintln!("Starting Nickel roundtrip workflow with TUI backend");
|
||||
}
|
||||
|
||||
// Create TUI backend
|
||||
let mut backend = BackendFactory::create(BackendType::Tui)?;
|
||||
|
||||
// Create roundtrip config
|
||||
let mut config = RoundtripConfig::with_template(input, form, output, ncl_template);
|
||||
let mut config = RoundtripConfig::with_template(input, form_path, output, ncl_template);
|
||||
config.validate = validate;
|
||||
config.verbose = verbose;
|
||||
|
||||
// Execute roundtrip
|
||||
let result = config.execute()?;
|
||||
// Execute roundtrip with TUI backend
|
||||
let result = config.execute_with_backend(backend.as_mut()).await?;
|
||||
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
|
||||
@ -199,15 +633,23 @@ async fn nickel_roundtrip_cmd(
|
||||
// Print summary
|
||||
println!("✓ Roundtrip completed successfully (TUI backend)");
|
||||
println!(" Input fields: {}", result.form_results.len());
|
||||
println!(" Imports preserved: {}", result.input_contracts.imports.len());
|
||||
println!(" Contracts preserved: {}", result.input_contracts.field_contracts.len());
|
||||
println!(
|
||||
" Imports preserved: {}",
|
||||
result.input_contracts.imports.len()
|
||||
);
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
result.input_contracts.field_contracts.len()
|
||||
);
|
||||
|
||||
if let Some(passed) = result.validation_passed {
|
||||
if passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
return Err(Error::validation_failed("Nickel typecheck failed on output"));
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
||||
@ -4,14 +4,13 @@
|
||||
//! Uses axum web framework with HTMX for dynamic form interactions.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use typedialog_core::{form_parser, Error, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::nickel::RoundtripConfig;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::{form_parser, helpers, Error, Result};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
@ -25,13 +24,25 @@ struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Path to TOML form configuration file (for default web command)
|
||||
/// Path to TOML form configuration file
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Port to listen on (can also be set via TYPEDIALOG_PORT env var)
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
port: u16,
|
||||
|
||||
/// Path to JSON file with default field values
|
||||
#[arg(long)]
|
||||
defaults: Option<PathBuf>,
|
||||
|
||||
/// Output file for form results
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Output format: json, yaml, text, toml
|
||||
#[arg(short = 'f', long, default_value = "json")]
|
||||
format: String,
|
||||
|
||||
/// Locale override for form localization
|
||||
#[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)]
|
||||
locale: Option<String>,
|
||||
@ -39,7 +50,7 @@ struct Args {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Execute interactive form from TOML configuration
|
||||
/// Execute interactive form from TOML configuration via HTTP server
|
||||
Form {
|
||||
/// Path to TOML form configuration file
|
||||
config: PathBuf,
|
||||
@ -49,7 +60,7 @@ enum Commands {
|
||||
defaults: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Complete roundtrip: .ncl → form → .ncl with preserved validators
|
||||
/// Complete roundtrip: .ncl → form → .ncl (interactive via HTTP)
|
||||
#[command(name = "nickel-roundtrip")]
|
||||
NickelRoundtrip {
|
||||
/// Path to input Nickel file (.ncl)
|
||||
@ -76,13 +87,98 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
/// Recursively flatten nested JSON objects into a single-level map
|
||||
/// Converts {"a": {"b": {"c": "value"}}} to {"a_b_c": "value"}
|
||||
fn flatten_json_object(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> std::collections::HashMap<String, serde_json::Value> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
flatten_recursive(obj, "", &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn flatten_recursive(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
prefix: &str,
|
||||
result: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
for (key, value) in obj.iter() {
|
||||
let new_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}_{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
serde_json::Value::Object(nested) => {
|
||||
// Recursively flatten nested objects
|
||||
flatten_recursive(nested, &new_key, result);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
// For arrays, just store them as-is with their key
|
||||
result.insert(new_key, serde_json::Value::Array(arr.clone()));
|
||||
}
|
||||
_ => {
|
||||
// Keep primitive values (string, number, bool, null)
|
||||
result.insert(new_key, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common field values from Nickel schema export structure
|
||||
/// Maps from nested structure (e.g., tracker.core.http.bind_address) to form field names
|
||||
fn extract_value_by_path(obj: &serde_json::Value, path: &[String]) -> Option<serde_json::Value> {
|
||||
if path.is_empty() {
|
||||
return Some(obj.clone());
|
||||
}
|
||||
|
||||
let key = &path[0];
|
||||
let remaining = &path[1..];
|
||||
|
||||
match obj {
|
||||
serde_json::Value::Object(map) => map
|
||||
.get(key)
|
||||
.and_then(|v| extract_value_by_path(v, remaining)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_nickel_defaults(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
form_fields: &[form_parser::FieldDefinition],
|
||||
) -> std::collections::HashMap<String, serde_json::Value> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
// Extract values using nickel_path from each field (handles arrays and complex structures)
|
||||
for field in form_fields {
|
||||
if let Some(nickel_path) = &field.nickel_path {
|
||||
if let Some(value) =
|
||||
extract_value_by_path(&serde_json::Value::Object(obj.clone()), nickel_path)
|
||||
{
|
||||
result.insert(field.name.clone(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Some(Commands::Form { config, defaults }) => {
|
||||
execute_form(config, defaults, args.port, &args.locale).await?;
|
||||
execute_form(
|
||||
config,
|
||||
defaults,
|
||||
args.port,
|
||||
args.output,
|
||||
&args.format,
|
||||
&args.locale,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(Commands::NickelRoundtrip {
|
||||
input,
|
||||
@ -95,31 +191,125 @@ async fn main() -> Result<()> {
|
||||
nickel_roundtrip_cmd(input, form, output, ncl_template, !no_validate, verbose).await?;
|
||||
}
|
||||
None => {
|
||||
let config = args.config.ok_or_else(|| Error::validation_failed("Please provide a form configuration file"))?;
|
||||
execute_form(config, None, args.port, &args.locale).await?;
|
||||
let config = args.config.ok_or_else(|| {
|
||||
Error::validation_failed("Please provide a form configuration file")
|
||||
})?;
|
||||
execute_form(
|
||||
config,
|
||||
args.defaults,
|
||||
args.port,
|
||||
args.output,
|
||||
&args.format,
|
||||
&args.locale,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, port: u16, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
async fn execute_form(
|
||||
config: PathBuf,
|
||||
defaults: Option<PathBuf>,
|
||||
port: u16,
|
||||
output: Option<PathBuf>,
|
||||
format: &str,
|
||||
cli_locale: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config).map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
let mut form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// Web backend uses unified elements array internally, migrate if using legacy format
|
||||
form.migrate_to_elements();
|
||||
|
||||
// Extract base directory for resolving relative paths in includes
|
||||
let base_dir = config
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let base_dir = config.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
|
||||
// Load default values from JSON file if provided
|
||||
// Expand groups with includes to load fragment files
|
||||
form = form_parser::expand_includes(form, base_dir)?;
|
||||
|
||||
// Load default values from JSON or .ncl file if provided
|
||||
let initial_values = if let Some(defaults_path) = defaults {
|
||||
let defaults_content = fs::read_to_string(&defaults_path)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to read defaults file: {}", e)))?;
|
||||
let defaults_json: std::collections::HashMap<String, serde_json::Value> = serde_json::from_str(&defaults_content)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to parse defaults JSON: {}", e)))?;
|
||||
use typedialog_core::nickel::NickelCli;
|
||||
|
||||
let is_ncl = defaults_path.extension().and_then(|s| s.to_str()) == Some("ncl");
|
||||
|
||||
let defaults_json: std::collections::HashMap<String, serde_json::Value> = if is_ncl {
|
||||
// Convert .ncl to JSON using nickel export
|
||||
NickelCli::verify()?;
|
||||
let value = NickelCli::export(&defaults_path)?;
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
// Extract fields from elements (which includes fragments after expand_includes)
|
||||
let form_fields: Vec<form_parser::FieldDefinition> = form
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|elem| match elem {
|
||||
form_parser::FormElement::Field(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract Nickel structure AND flatten everything (combine both approaches)
|
||||
let mut combined = extract_nickel_defaults(&map, &form_fields);
|
||||
let flattened = flatten_json_object(&map);
|
||||
|
||||
// Flattened values fill in gaps not covered by extraction
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults .ncl must export to a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Read JSON directly - combine extraction and flatten
|
||||
let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to read defaults file: {}", e))
|
||||
})?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&defaults_content).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to parse defaults JSON: {}", e))
|
||||
})?;
|
||||
match parsed {
|
||||
serde_json::Value::Object(map) => {
|
||||
// Extract fields from elements (which includes fragments after expand_includes)
|
||||
let form_fields: Vec<form_parser::FieldDefinition> = form
|
||||
.elements
|
||||
.iter()
|
||||
.filter_map(|elem| match elem {
|
||||
form_parser::FormElement::Field(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut combined = extract_nickel_defaults(&map, &form_fields);
|
||||
let flattened = flatten_json_object(&map);
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults must be a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !defaults_json.is_empty() {
|
||||
eprintln!(
|
||||
"[web] Loaded {} default field values from --defaults",
|
||||
defaults_json.len()
|
||||
);
|
||||
}
|
||||
Some(defaults_json)
|
||||
} else {
|
||||
None
|
||||
@ -131,7 +321,8 @@ async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, port: u16, cli
|
||||
let resolver = LocaleResolver::new(config.clone());
|
||||
let form_locale = form.locale.as_deref();
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
let fallback_locale: LanguageIdentifier = "en-US"
|
||||
.parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
let loader = LocaleLoader::new(config.locales_path);
|
||||
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
|
||||
@ -143,52 +334,199 @@ async fn execute_form(config: PathBuf, defaults: Option<PathBuf>, port: u16, cli
|
||||
|
||||
println!("Starting typedialog web server for form: {}", form.name);
|
||||
println!("Listening on http://localhost:{}", port);
|
||||
if let Some(ref out_path) = output {
|
||||
println!("Results will be saved to: {}", out_path.display());
|
||||
}
|
||||
|
||||
let _results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), Some(bundle), base_dir, initial_values).await?
|
||||
let results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_i18n_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
Some(bundle),
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), None, base_dir, initial_values).await?
|
||||
form_parser::execute_with_backend_i18n_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
None,
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Save results to output file if specified
|
||||
if let Some(out_path) = output {
|
||||
let output_str = helpers::format_results(&results, format)?;
|
||||
fs::write(&out_path, output_str)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to write output file: {}", e)))?;
|
||||
eprintln!("[web] Results saved to: {}", out_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nickel_roundtrip_cmd(
|
||||
input: PathBuf,
|
||||
form: PathBuf,
|
||||
form_path: PathBuf,
|
||||
output: PathBuf,
|
||||
ncl_template: Option<PathBuf>,
|
||||
validate: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
if verbose {
|
||||
eprintln!("Starting Nickel roundtrip workflow with Web backend");
|
||||
}
|
||||
|
||||
// Create roundtrip config
|
||||
let mut config = RoundtripConfig::with_template(input, form, output, ncl_template);
|
||||
config.validate = validate;
|
||||
config.verbose = verbose;
|
||||
|
||||
// Execute roundtrip
|
||||
let result = config.execute()?;
|
||||
use typedialog_core::nickel::{
|
||||
ContractParser, NickelCli, NickelTemplateContext, TemplateEngine,
|
||||
};
|
||||
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
|
||||
eprintln!("Starting Nickel roundtrip workflow with Web backend (interactive)");
|
||||
}
|
||||
|
||||
// Step 1: Read input and parse contracts
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Reading input: {}", input.display());
|
||||
}
|
||||
|
||||
let input_source = fs::read_to_string(&input)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to read input: {}", e)))?;
|
||||
|
||||
let input_contracts = ContractParser::parse_source(&input_source)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to parse contracts: {}", e)))?;
|
||||
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} imports",
|
||||
input_contracts.imports.len()
|
||||
);
|
||||
eprintln!(
|
||||
"[roundtrip] Found {} contract calls",
|
||||
input_contracts.field_contracts.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Load form definition
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Loading form: {}", form_path.display());
|
||||
}
|
||||
|
||||
let form_content = fs::read_to_string(&form_path)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to read form: {}", e)))?;
|
||||
|
||||
let mut form = form_parser::parse_toml(&form_content)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to parse form: {}", e)))?;
|
||||
|
||||
// Web backend uses unified elements array internally, migrate if using legacy format
|
||||
form.migrate_to_elements();
|
||||
|
||||
let form_base_dir = form_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
|
||||
// Expand groups with includes to load fragment files
|
||||
form = form_parser::expand_includes(form, form_base_dir)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to expand includes: {}", e)))?;
|
||||
|
||||
// Step 3: Execute form with Web backend (interactive HTTP server)
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Starting HTTP server for interactive form");
|
||||
}
|
||||
|
||||
let port = 8080;
|
||||
let mut backend = BackendFactory::create(BackendType::Web { port })?;
|
||||
|
||||
println!("Starting interactive form on http://localhost:{}", port);
|
||||
println!("Complete the form and submit to continue...\n");
|
||||
|
||||
let form_results =
|
||||
form_parser::execute_with_backend_two_phase(form, backend.as_mut(), None, form_base_dir)
|
||||
.await?;
|
||||
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Form execution produced {} fields",
|
||||
form_results.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Generate output Nickel
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Rendering output Nickel code");
|
||||
}
|
||||
|
||||
let output_nickel = if let Some(template_path) = ncl_template {
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Using template: {}", template_path.display());
|
||||
}
|
||||
let mut engine = TemplateEngine::new();
|
||||
engine
|
||||
.render_file(template_path.as_path(), &form_results, None)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to render template: {}", e)))?
|
||||
} else {
|
||||
let ctx = NickelTemplateContext::from_parsed_contracts(
|
||||
input_contracts.clone(),
|
||||
form_results.clone(),
|
||||
);
|
||||
ctx.render_full_nickel()
|
||||
};
|
||||
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"[roundtrip] Generated {} bytes of Nickel code",
|
||||
output_nickel.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Write output
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Writing output: {}", output.display());
|
||||
}
|
||||
|
||||
fs::write(&output, &output_nickel)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to write output: {}", e)))?;
|
||||
|
||||
// Step 6: Validate if requested
|
||||
let validation_passed = if validate {
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] Validating with nickel typecheck");
|
||||
}
|
||||
match NickelCli::typecheck(&output) {
|
||||
Ok(()) => {
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] ✓ Validation passed");
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
if verbose {
|
||||
eprintln!("[roundtrip] ✗ Validation failed: {}", e);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
// Print summary
|
||||
println!("✓ Roundtrip completed successfully (Web backend)");
|
||||
println!(" Input fields: {}", result.form_results.len());
|
||||
println!(" Imports preserved: {}", result.input_contracts.imports.len());
|
||||
println!(" Contracts preserved: {}", result.input_contracts.field_contracts.len());
|
||||
println!("✓ Roundtrip completed successfully (Web backend - interactive)");
|
||||
println!(" Input fields: {}", form_results.len());
|
||||
println!(" Imports preserved: {}", input_contracts.imports.len());
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
input_contracts.field_contracts.len()
|
||||
);
|
||||
|
||||
if let Some(passed) = result.validation_passed {
|
||||
if passed {
|
||||
if validate {
|
||||
if validation_passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
return Err(Error::validation_failed("Nickel typecheck failed on output"));
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,17 +4,20 @@
|
||||
//! Works with piped input for batch processing and scripts.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use typedialog_core::{prompts, form_parser, Error, Result};
|
||||
use typedialog_core::backends::BackendFactory;
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::nickel::{NickelCli, MetadataParser, TomlGenerator, TemplateEngine, I18nExtractor};
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::cli_common;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use typedialog_core::backends::BackendFactory;
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::nickel::{
|
||||
DefaultsExtractor, FieldMapper, I18nExtractor, MetadataParser, NickelCli, NickelFieldIR,
|
||||
NickelSchemaIR, TemplateEngine, TomlGenerator,
|
||||
};
|
||||
use typedialog_core::{form_parser, prompts, Error, Result};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
@ -338,12 +341,30 @@ async fn main() -> Result<()> {
|
||||
max_date,
|
||||
week_start,
|
||||
} => {
|
||||
let result = prompts::date(&prompt, default.as_deref(), min_date.as_deref(), max_date.as_deref(), &week_start)?;
|
||||
let result = prompts::date(
|
||||
&prompt,
|
||||
default.as_deref(),
|
||||
min_date.as_deref(),
|
||||
max_date.as_deref(),
|
||||
&week_start,
|
||||
)?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Form { config, template, defaults } => {
|
||||
execute_form(config, template, defaults, &cli.format, &cli.out, &cli.locale).await?;
|
||||
Commands::Form {
|
||||
config,
|
||||
template,
|
||||
defaults,
|
||||
} => {
|
||||
execute_form(
|
||||
config,
|
||||
template,
|
||||
defaults,
|
||||
&cli.format,
|
||||
&cli.out,
|
||||
&cli.locale,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::NickelToForm {
|
||||
@ -356,7 +377,16 @@ async fn main() -> Result<()> {
|
||||
i18n,
|
||||
output,
|
||||
} => {
|
||||
nickel_to_form_cmd(schema, current_data, flatten, groups, fragments, conditionals, i18n, output)?;
|
||||
nickel_to_form_cmd(
|
||||
schema,
|
||||
current_data,
|
||||
flatten,
|
||||
groups,
|
||||
fragments,
|
||||
conditionals,
|
||||
i18n,
|
||||
output,
|
||||
)?;
|
||||
}
|
||||
|
||||
Commands::FormToNickel {
|
||||
@ -367,10 +397,7 @@ async fn main() -> Result<()> {
|
||||
form_to_nickel_cmd(form, input, &cli.out, validate)?;
|
||||
}
|
||||
|
||||
Commands::NickelTemplate {
|
||||
template,
|
||||
results,
|
||||
} => {
|
||||
Commands::NickelTemplate { template, results } => {
|
||||
nickel_template_cmd(template, results, &cli.out)?;
|
||||
}
|
||||
|
||||
@ -389,23 +416,177 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, template: Option<PathBuf>, defaults: Option<PathBuf>, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
/// Recursively flatten nested JSON objects into a single-level map
|
||||
/// Converts {"a": {"b": {"c": "value"}}} to {"a_b_c": "value"}
|
||||
fn flatten_json_object(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut result = HashMap::new();
|
||||
flatten_recursive(obj, "", &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn flatten_recursive(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
prefix: &str,
|
||||
result: &mut HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
for (key, value) in obj.iter() {
|
||||
let new_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}_{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
serde_json::Value::Object(nested) => {
|
||||
// Recursively flatten nested objects
|
||||
flatten_recursive(nested, &new_key, result);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
// For arrays, just store them as-is with their key
|
||||
result.insert(new_key, serde_json::Value::Array(arr.clone()));
|
||||
}
|
||||
_ => {
|
||||
// Keep primitive values (string, number, bool, null)
|
||||
result.insert(new_key, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract defaults from Nickel export using schema-driven approach
|
||||
///
|
||||
/// Builds a minimal schema from the form fields to enable DefaultsExtractor,
|
||||
/// which provides generic, maintainable default extraction.
|
||||
fn extract_nickel_defaults(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
form_fields: &[form_parser::FieldDefinition],
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
// Build a minimal schema from form fields that have nickel_path
|
||||
let mut schema_fields = Vec::new();
|
||||
for field in form_fields {
|
||||
if let Some(nickel_path) = &field.nickel_path {
|
||||
schema_fields.push(NickelFieldIR {
|
||||
path: nickel_path.clone(),
|
||||
flat_name: nickel_path.join("-"),
|
||||
alias: field.nickel_alias.clone(),
|
||||
nickel_type: typedialog_core::nickel::NickelType::String, // Type doesn't matter for extraction
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
contract_call: None,
|
||||
group: None,
|
||||
fragment_marker: None,
|
||||
is_array_of_records: false,
|
||||
array_element_fields: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have schema fields, use DefaultsExtractor
|
||||
if !schema_fields.is_empty() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "form".to_string(),
|
||||
description: None,
|
||||
fields: schema_fields,
|
||||
};
|
||||
if let Ok(mapper) = FieldMapper::from_schema(&schema) {
|
||||
return DefaultsExtractor::extract(&serde_json::Value::Object(obj.clone()), &mapper);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: flatten everything if schema-driven extraction fails
|
||||
flatten_json_object(obj)
|
||||
}
|
||||
|
||||
async fn execute_form(
|
||||
config: PathBuf,
|
||||
template: Option<PathBuf>,
|
||||
defaults: Option<PathBuf>,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
cli_locale: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config).map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// Extract base directory for resolving relative paths in includes
|
||||
let base_dir = config
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let base_dir = config.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
|
||||
// Load default values from JSON file if provided
|
||||
// Note: migrate_to_elements() and expand_includes() are handled internally
|
||||
// by execute_with_backend_two_phase_with_defaults()
|
||||
|
||||
// Load default values from JSON or .ncl file if provided
|
||||
let initial_values = if let Some(defaults_path) = defaults {
|
||||
let defaults_content = fs::read_to_string(&defaults_path)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to read defaults file: {}", e)))?;
|
||||
let defaults_json: HashMap<String, serde_json::Value> = serde_json::from_str(&defaults_content)
|
||||
.map_err(|e| Error::validation_failed(format!("Failed to parse defaults JSON: {}", e)))?;
|
||||
use typedialog_core::nickel::NickelCli;
|
||||
|
||||
let is_ncl = defaults_path.extension().and_then(|s| s.to_str()) == Some("ncl");
|
||||
|
||||
let defaults_json: HashMap<String, serde_json::Value> = if is_ncl {
|
||||
// Convert .ncl to JSON using nickel export
|
||||
NickelCli::verify()?;
|
||||
let value = NickelCli::export(&defaults_path)?;
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
// Use schema-driven extraction with form fields, fallback to flattening
|
||||
let extracted = extract_nickel_defaults(&map, &form.fields);
|
||||
// Also flatten to catch any fields not in the form definition
|
||||
let flattened = flatten_json_object(&map);
|
||||
// Merge: extracted values + flattened fill gaps
|
||||
let mut combined = extracted;
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults .ncl must export to a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Read JSON directly - combine extraction and flatten
|
||||
let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to read defaults file: {}", e))
|
||||
})?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&defaults_content).map_err(|e| {
|
||||
Error::validation_failed(format!("Failed to parse defaults JSON: {}", e))
|
||||
})?;
|
||||
match parsed {
|
||||
serde_json::Value::Object(map) => {
|
||||
let extracted = extract_nickel_defaults(&map, &form.fields);
|
||||
let flattened = flatten_json_object(&map);
|
||||
let mut combined = extracted;
|
||||
for (k, v) in flattened {
|
||||
combined.entry(k).or_insert(v);
|
||||
}
|
||||
combined
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::validation_failed(
|
||||
"Defaults must be a JSON object".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !defaults_json.is_empty() {
|
||||
eprintln!(
|
||||
"[DEBUG] Loaded {} default field values",
|
||||
defaults_json.len()
|
||||
);
|
||||
for key in defaults_json.keys().take(5) {
|
||||
eprintln!("[DEBUG] - {}", key);
|
||||
}
|
||||
if defaults_json.len() > 5 {
|
||||
eprintln!("[DEBUG] ... and {} more", defaults_json.len() - 5);
|
||||
}
|
||||
}
|
||||
Some(defaults_json)
|
||||
} else {
|
||||
None
|
||||
@ -420,7 +601,8 @@ async fn execute_form(config: PathBuf, template: Option<PathBuf>, defaults: Opti
|
||||
|
||||
// resolve() already returns a LanguageIdentifier
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
let fallback_locale: LanguageIdentifier = "en-US"
|
||||
.parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
|
||||
// Load translations
|
||||
@ -436,16 +618,30 @@ async fn execute_form(config: PathBuf, template: Option<PathBuf>, defaults: Opti
|
||||
|
||||
// Execute form using two-phase execution (selector fields -> dynamic loading -> remaining fields)
|
||||
let results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), Some(bundle), base_dir, initial_values).await?
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
Some(bundle),
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), None, base_dir, initial_values).await?
|
||||
form_parser::execute_with_backend_two_phase_with_defaults(
|
||||
form,
|
||||
backend.as_mut(),
|
||||
None,
|
||||
base_dir,
|
||||
initial_values,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// If template provided, generate Nickel output directly
|
||||
if let Some(template_path) = template {
|
||||
// Load and render template with form results
|
||||
let mut engine = TemplateEngine::new();
|
||||
let nickel_output = engine.render_file(template_path.as_path(), &results)?;
|
||||
let nickel_output = engine.render_file(template_path.as_path(), &results, None)?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output_file {
|
||||
@ -461,12 +657,7 @@ async fn execute_form(config: PathBuf, template: Option<PathBuf>, defaults: Opti
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_result(
|
||||
key: &str,
|
||||
value: &str,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
fn print_result(key: &str, value: &str, format: &str, output_file: &Option<PathBuf>) -> Result<()> {
|
||||
let output = match format {
|
||||
"json" => {
|
||||
let mut map = HashMap::new();
|
||||
@ -514,7 +705,7 @@ fn nickel_to_form_cmd(
|
||||
flatten: bool,
|
||||
groups: bool,
|
||||
fragments: bool,
|
||||
_conditionals: bool, // Conditionals are auto-generated by ContractAnalyzer in TomlGenerator
|
||||
_conditionals: bool, // Conditionals are auto-generated by ContractAnalyzer in TomlGenerator
|
||||
i18n: bool,
|
||||
output_dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
@ -534,7 +725,8 @@ fn nickel_to_form_cmd(
|
||||
}
|
||||
|
||||
// Step 2: Generate TOML form(s)
|
||||
let forms_output = if fragments && schema_ir.fields.iter().any(|f| f.fragment_marker.is_some()) {
|
||||
let forms_output = if fragments && schema_ir.fields.iter().any(|f| f.fragment_marker.is_some())
|
||||
{
|
||||
// Multi-file output: main form + fragments
|
||||
TomlGenerator::generate_with_fragments(&schema_ir, flatten, groups)?
|
||||
} else {
|
||||
@ -568,7 +760,9 @@ fn nickel_to_form_cmd(
|
||||
|
||||
for (filename, form_def) in forms_output {
|
||||
let file_path = if filename.starts_with("fragments/") {
|
||||
output_path.join("fragments").join(filename.strip_prefix("fragments/").unwrap())
|
||||
output_path
|
||||
.join("fragments")
|
||||
.join(filename.strip_prefix("fragments/").unwrap())
|
||||
} else {
|
||||
output_path.join(&filename)
|
||||
};
|
||||
@ -590,7 +784,10 @@ fn nickel_to_form_cmd(
|
||||
let i18n_output_dir = output_path.join("locales");
|
||||
let _i18n_mapping = I18nExtractor::extract_and_generate(&schema_ir, &i18n_output_dir)?;
|
||||
|
||||
eprintln!("✓ i18n translations generated in {}/", i18n_output_dir.display());
|
||||
eprintln!(
|
||||
"✓ i18n translations generated in {}/",
|
||||
i18n_output_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -606,7 +803,9 @@ fn form_to_nickel_cmd(
|
||||
let _form_def = form_parser::parse_toml(&form_content)?;
|
||||
|
||||
// Determine input type based on extension
|
||||
let results: HashMap<String, serde_json::Value> = if input.extension().and_then(|s| s.to_str()) == Some("ncl.j2") {
|
||||
let results: HashMap<String, serde_json::Value> = if input.extension().and_then(|s| s.to_str())
|
||||
== Some("ncl.j2")
|
||||
{
|
||||
// Template: would require executing form and rendering template
|
||||
// For now, return error as this requires interactive execution
|
||||
return Err(Error::validation_failed(
|
||||
@ -618,14 +817,16 @@ fn form_to_nickel_cmd(
|
||||
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?
|
||||
} else {
|
||||
return Err(Error::validation_failed(
|
||||
"Input file must be .json or .ncl.j2"
|
||||
"Input file must be .json or .ncl.j2",
|
||||
));
|
||||
};
|
||||
|
||||
// For now, provide a placeholder message as full Nickel serialization requires schema
|
||||
let nickel_output = format!("# Form results (JSON format for now)\n{}",
|
||||
let nickel_output = format!(
|
||||
"# Form results (JSON format for now)\n{}",
|
||||
serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?);
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?
|
||||
);
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
@ -646,12 +847,11 @@ fn nickel_template_cmd(
|
||||
// Load results JSON file
|
||||
let json_content = fs::read_to_string(&results).map_err(Error::io)?;
|
||||
let values: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(&json_content)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
|
||||
// Load and render template
|
||||
let mut engine = TemplateEngine::new();
|
||||
let nickel_output = engine.render_file(template.as_path(), &values)?;
|
||||
let nickel_output = engine.render_file(template.as_path(), &values, None)?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
@ -693,15 +893,23 @@ fn nickel_roundtrip_cmd(
|
||||
// Print summary
|
||||
println!("✓ Roundtrip completed successfully");
|
||||
println!(" Input fields: {}", result.form_results.len());
|
||||
println!(" Imports preserved: {}", result.input_contracts.imports.len());
|
||||
println!(" Contracts preserved: {}", result.input_contracts.field_contracts.len());
|
||||
println!(
|
||||
" Imports preserved: {}",
|
||||
result.input_contracts.imports.len()
|
||||
);
|
||||
println!(
|
||||
" Contracts preserved: {}",
|
||||
result.input_contracts.field_contracts.len()
|
||||
);
|
||||
|
||||
if let Some(passed) = result.validation_passed {
|
||||
if passed {
|
||||
println!(" ✓ Validation: PASSED");
|
||||
} else {
|
||||
println!(" ✗ Validation: FAILED");
|
||||
return Err(Error::validation_failed("Nickel typecheck failed on output"));
|
||||
return Err(Error::validation_failed(
|
||||
"Nickel typecheck failed on output",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -80,17 +80,35 @@ typedialog-tui --config config/tui/production.toml form.toml
|
||||
| dev | Development | Enabled | No | Unlimited |
|
||||
| production | Production | Restricted | Required | 100/min |
|
||||
|
||||
**Usage:**
|
||||
**Basic Usage:**
|
||||
```bash
|
||||
typedialog-web --config config/web/production.toml
|
||||
typedialog-web form.toml
|
||||
# Server starts on http://localhost:8080
|
||||
```
|
||||
|
||||
**With Options:**
|
||||
```bash
|
||||
# Load defaults from .ncl file
|
||||
typedialog-web form.toml --defaults values.ncl
|
||||
|
||||
# Save results to file after form submission
|
||||
typedialog-web form.toml --output result.json
|
||||
|
||||
# Combine with custom locale
|
||||
typedialog-web form.toml --defaults config.ncl --output result.json --locale es-ES
|
||||
|
||||
# Run on custom port
|
||||
typedialog-web form.toml --port 9000
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- HTML/CSS rendering
|
||||
- CSRF protection
|
||||
- Response caching
|
||||
- Gzip compression
|
||||
- Array field management (RepeatingGroup)
|
||||
- Default value loading from Nickel (.ncl) or JSON
|
||||
- Output to file (JSON format)
|
||||
|
||||
## Configuration by Environment
|
||||
|
||||
|
||||
506
docs/FIELD_TYPES.md
Normal file
506
docs/FIELD_TYPES.md
Normal file
@ -0,0 +1,506 @@
|
||||
<div align="center">
|
||||
<img src="../imgs/typedialog_logo_h_s.svg" alt="TypeDialog Logo" width="600" />
|
||||
</div>
|
||||
|
||||
# Field Types Reference
|
||||
|
||||
Complete reference for all supported field types in TypeDialog forms.
|
||||
|
||||
## Overview
|
||||
|
||||
TypeDialog supports multiple field types for different input scenarios. Each backend (CLI, TUI, Web) renders these types appropriately for its interface.
|
||||
|
||||
## Supported Field Types
|
||||
|
||||
### Text
|
||||
Single-line text input.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "username"
|
||||
type = "text"
|
||||
prompt = "Enter username"
|
||||
placeholder = "john_doe"
|
||||
default = "admin"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `placeholder`: Example text
|
||||
- `default`: Pre-filled value
|
||||
- `required`: Validation flag
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Inline text prompt with validation
|
||||
- TUI: Text input field in input panel
|
||||
- Web: HTML `<input type="text">`
|
||||
|
||||
---
|
||||
|
||||
### Password
|
||||
Secure password input (masked characters).
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "api_token"
|
||||
type = "password"
|
||||
prompt = "API Token"
|
||||
required = true
|
||||
help = "Your secret authentication token"
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `required`: Validation flag
|
||||
- `help`: Additional guidance
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Hidden input with asterisks
|
||||
- TUI: Masked input field
|
||||
- Web: HTML `<input type="password">`
|
||||
|
||||
---
|
||||
|
||||
### Confirm
|
||||
Boolean yes/no selection.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "enable_feature"
|
||||
type = "confirm"
|
||||
prompt = "Enable experimental features?"
|
||||
default = false
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Question to display
|
||||
- `default`: Initial value (true/false)
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Yes/No buttons
|
||||
- TUI: Radio buttons (Yes/No)
|
||||
- Web: Radio buttons or toggle switch
|
||||
|
||||
---
|
||||
|
||||
### Select
|
||||
Single selection from predefined options.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "database_driver"
|
||||
type = "select"
|
||||
prompt = "Database type"
|
||||
required = true
|
||||
|
||||
[[elements.options]]
|
||||
value = "sqlite3"
|
||||
label = "SQLite (embedded)"
|
||||
|
||||
[[elements.options]]
|
||||
value = "mysql"
|
||||
label = "MySQL (server)"
|
||||
|
||||
[[elements.options]]
|
||||
value = "postgresql"
|
||||
label = "PostgreSQL (server)"
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `options`: Array of value/label pairs
|
||||
- `required`: Validation flag
|
||||
- `default`: Pre-selected value
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Arrow-key navigation list
|
||||
- TUI: Dropdown or scrollable list
|
||||
- Web: HTML `<select>` dropdown
|
||||
|
||||
---
|
||||
|
||||
### MultiSelect
|
||||
Multiple selections from predefined options.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "features"
|
||||
type = "multiselect"
|
||||
prompt = "Enable features"
|
||||
page_size = 10
|
||||
vim_mode = false
|
||||
|
||||
[[elements.options]]
|
||||
value = "prometheus"
|
||||
label = "Prometheus Metrics"
|
||||
|
||||
[[elements.options]]
|
||||
value = "grafana"
|
||||
label = "Grafana Dashboard"
|
||||
|
||||
[[elements.options]]
|
||||
value = "auth"
|
||||
label = "Authentication"
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `options`: Array of value/label pairs
|
||||
- `page_size`: Number of visible items
|
||||
- `vim_mode`: Enable vim navigation
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Checkbox list with space to toggle
|
||||
- TUI: Multi-selection list with checkboxes
|
||||
- Web: HTML checkboxes in group
|
||||
|
||||
---
|
||||
|
||||
### Date
|
||||
Date selection with validation.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "start_date"
|
||||
type = "date"
|
||||
prompt = "Project start date"
|
||||
min_date = "2024-01-01"
|
||||
max_date = "2025-12-31"
|
||||
week_start = "Mon"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `min_date`: Earliest allowed date (ISO 8601)
|
||||
- `max_date`: Latest allowed date (ISO 8601)
|
||||
- `week_start`: Calendar week start day
|
||||
- `default`: Pre-filled date
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Date input with format validation
|
||||
- TUI: Calendar picker widget
|
||||
- Web: HTML `<input type="date">`
|
||||
|
||||
---
|
||||
|
||||
### Editor
|
||||
Multi-line text editor for code/configuration.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "config_file"
|
||||
type = "editor"
|
||||
prompt = "Configuration content"
|
||||
file_extension = "toml"
|
||||
prefix_text = "[settings]\n"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label
|
||||
- `file_extension`: File type for syntax highlighting
|
||||
- `prefix_text`: Pre-filled content
|
||||
- `required`: Validation flag
|
||||
|
||||
**Backend rendering**:
|
||||
- CLI: Opens external editor ($EDITOR)
|
||||
- TUI: Built-in multi-line editor
|
||||
- Web: HTML `<textarea>` with monospace font
|
||||
|
||||
---
|
||||
|
||||
### Custom
|
||||
Custom type with external validation.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "port"
|
||||
type = "custom"
|
||||
custom_type = "Port"
|
||||
prompt = "Server port"
|
||||
placeholder = "8080"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `custom_type`: Type name for validation
|
||||
- `prompt`: Display label
|
||||
- `placeholder`: Example value
|
||||
- `required`: Validation flag
|
||||
|
||||
**Backend rendering**:
|
||||
- All backends: Text input with custom validator
|
||||
- Validation delegated to Nickel contracts
|
||||
|
||||
---
|
||||
|
||||
### RepeatingGroup
|
||||
Dynamic array of structured items (add/edit/delete).
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "udp_trackers"
|
||||
type = "repeatinggroup"
|
||||
prompt = "UDP Tracker Listeners"
|
||||
fragment = "fragments/udp_trackers_item.toml"
|
||||
min_items = 0
|
||||
max_items = 10
|
||||
default_items = 1
|
||||
unique = true
|
||||
required = false
|
||||
help = "Configure UDP tracker bind addresses"
|
||||
```
|
||||
|
||||
**Attributes**:
|
||||
- `prompt`: Display label for the array
|
||||
- `fragment`: Path to fragment file defining array element structure
|
||||
- `min_items`: Minimum required items (0 = optional)
|
||||
- `max_items`: Maximum allowed items
|
||||
- `default_items`: Initial number of items to show
|
||||
- `unique`: Enforce uniqueness constraint (all fields must differ between items)
|
||||
- `required`: Whether array is required
|
||||
- `help`: Additional guidance text
|
||||
|
||||
**Uniqueness Constraints** (Web/TUI/CLI):
|
||||
- When `unique = true`: ALL fields in each item must be different from all other items
|
||||
- Example: Cannot add `{bind_address: "0.0.0.0:6969"}` twice
|
||||
- Triggers validation alert: "This item already exists"
|
||||
- Within a fragment, individual fields can be marked with `unique_key = true` to enforce uniqueness on that field only
|
||||
- Example: Port numbers must be unique, but bind addresses can repeat
|
||||
|
||||
**Fragment structure** (`udp_trackers_item.toml`):
|
||||
```toml
|
||||
name = "udp_trackers_item"
|
||||
description = "UDP Tracker configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "bind_address"
|
||||
type = "text"
|
||||
prompt = "UDP Bind Address"
|
||||
placeholder = "0.0.0.0:6969"
|
||||
default = "0.0.0.0:6969"
|
||||
required = true
|
||||
order = 0
|
||||
```
|
||||
|
||||
**Backend rendering**:
|
||||
- **CLI**: Interactive menu system ✓
|
||||
- `[A] Add new item` - Open fragment form for new item
|
||||
- `[E] Edit item` - Select and edit existing item
|
||||
- `[D] Delete item` - Select and remove item
|
||||
- `[C] Continue` - Validate min_items and proceed
|
||||
- ✅ **Duplicate detection enforced** if `unique = true`
|
||||
- ✅ **max_items limit enforced** with clear feedback
|
||||
|
||||
- **TUI**: Split-pane interface ✓
|
||||
- Left pane: List of items with navigation
|
||||
- Right pane: Preview or edit form
|
||||
- Keyboard: A=add, E=edit, D=delete, Enter=continue
|
||||
- ✅ **Duplicate detection enforced** if `unique = true`
|
||||
- ✅ **max_items limit enforced** with error overlay
|
||||
- Item counter showing progress
|
||||
|
||||
- **Web**: Inline expandable cards (no modal nesting) ✓
|
||||
- Item cards showing summary of field values
|
||||
- ➕ Add Item button expands inline form
|
||||
- ✏️ Edit button expands item for inline editing
|
||||
- 🗑️ Delete button with confirmation
|
||||
- Live counter: `Items: X / max_items`
|
||||
- ✅ **Duplicate detection enforced** if `unique = true`
|
||||
- ✅ **max_items limit enforced** with alert message
|
||||
- Independent state per repeating group
|
||||
|
||||
**JSON output**:
|
||||
```json
|
||||
{
|
||||
"udp_trackers": [
|
||||
{ "bind_address": "0.0.0.0:6969" },
|
||||
{ "bind_address": "0.0.0.0:6970" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Nickel schema mapping**:
|
||||
```nickel
|
||||
{
|
||||
TrackerUdp = { bind_address | String },
|
||||
Config = {
|
||||
udp_trackers | Array TrackerUdp | optional
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Multiple listeners (UDP/HTTP servers)
|
||||
- Array of database connections
|
||||
- List of API endpoints
|
||||
- Collection of users/accounts
|
||||
- Multiple environment variables
|
||||
- Array of network interfaces
|
||||
|
||||
---
|
||||
|
||||
## Display Elements (Non-Input)
|
||||
|
||||
These are not input fields but display-only elements for form organization.
|
||||
|
||||
### Section Header
|
||||
Visual separator with title.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "database_header"
|
||||
type = "section_header"
|
||||
title = "📊 Database Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
```
|
||||
|
||||
### Section
|
||||
Informational text block.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "info"
|
||||
type = "section"
|
||||
content = "Configure your database connection settings below."
|
||||
```
|
||||
|
||||
### Group
|
||||
Container for loading fragments.
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "mysql_group"
|
||||
type = "group"
|
||||
when = "database_driver == mysql"
|
||||
includes = ["fragments/database-mysql-section.toml"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conditional Display
|
||||
|
||||
All field types support conditional visibility via `when` attribute:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "mysql_password"
|
||||
type = "password"
|
||||
prompt = "MySQL Password"
|
||||
when = "database_driver == mysql"
|
||||
required = true
|
||||
```
|
||||
|
||||
**Supported operators**:
|
||||
- `==`: Equality
|
||||
- `!=`: Inequality
|
||||
- Parentheses for grouping (future)
|
||||
- Logical AND/OR (future)
|
||||
|
||||
---
|
||||
|
||||
## i18n Support
|
||||
|
||||
All fields support internationalization via `i18n = true`:
|
||||
|
||||
```toml
|
||||
[[elements]]
|
||||
name = "username"
|
||||
type = "text"
|
||||
prompt = "form.username.prompt"
|
||||
placeholder = "form.username.placeholder"
|
||||
i18n = true
|
||||
```
|
||||
|
||||
Translations resolved from Fluent `.ftl` files in `locales/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Nickel Integration
|
||||
|
||||
Fields automatically map to Nickel contracts:
|
||||
|
||||
| Field Type | Nickel Contract |
|
||||
|------------|----------------|
|
||||
| Text | `String` |
|
||||
| Password | `String` |
|
||||
| Confirm | `Bool` |
|
||||
| Select | `String` (enum) |
|
||||
| MultiSelect | `Array String` |
|
||||
| Date | `String` (ISO 8601) |
|
||||
| Editor | `String` |
|
||||
| Custom | Custom contract |
|
||||
| RepeatingGroup | `Array Record` |
|
||||
|
||||
Example Nickel schema:
|
||||
```nickel
|
||||
{
|
||||
Config = {
|
||||
username | String,
|
||||
password | String,
|
||||
enable_feature | Bool,
|
||||
database_driver | [| 'sqlite3, 'mysql, 'postgresql |],
|
||||
features | Array String,
|
||||
start_date | String,
|
||||
udp_trackers | Array { bind_address | String },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend-Specific Features
|
||||
|
||||
### CLI Backend
|
||||
- Inline validation with immediate feedback
|
||||
- Arrow key navigation for Select/MultiSelect
|
||||
- External editor support for Editor type
|
||||
- Interactive menu for RepeatingGroup
|
||||
|
||||
### TUI Backend
|
||||
- 3-panel layout (fields, input, preview)
|
||||
- Mouse and keyboard navigation
|
||||
- Real-time form updates
|
||||
- Split-pane UI for RepeatingGroup
|
||||
|
||||
### Web Backend
|
||||
- HTML5 input types for validation
|
||||
- CSS styling for consistent appearance
|
||||
- HTMX for reactive updates
|
||||
- Modal overlays for RepeatingGroup
|
||||
|
||||
---
|
||||
|
||||
## Field Validation
|
||||
|
||||
Validation occurs at multiple levels:
|
||||
|
||||
1. **Client-side** (TypeDialog):
|
||||
- Required fields
|
||||
- Field type constraints
|
||||
- Min/max values (dates, numbers)
|
||||
- Array min/max items (RepeatingGroup)
|
||||
|
||||
2. **Schema validation** (Nickel):
|
||||
- Contract enforcement
|
||||
- Custom predicates
|
||||
- Business logic rules
|
||||
|
||||
3. **Server-side** (Application):
|
||||
- Final validation before processing
|
||||
- Database constraints
|
||||
- External API verification
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CONFIGURATION.md](CONFIGURATION.md) - Backend configurations
|
||||
- [NICKEL.md](NICKEL.md) - Nickel schema integration
|
||||
- [fragments/README.md](../provisioning/fragments/README.md) - Fragment system
|
||||
- [examples/](../examples/) - Working examples
|
||||
|
||||
@ -40,6 +40,12 @@ Complete documentation for using, building, and deploying TypeDialog.
|
||||
- Custom configurations
|
||||
- Best practices
|
||||
|
||||
6. **[FIELD_TYPES.md](FIELD_TYPES.md)** - Field types reference
|
||||
- All supported field types
|
||||
- RepeatingGroup arrays
|
||||
- Conditional display
|
||||
- i18n support
|
||||
|
||||
## Quick Links
|
||||
|
||||
### Installation & Setup
|
||||
|
||||
@ -1,46 +1,56 @@
|
||||
name = "Simple Contact Form"
|
||||
description = "A basic contact form with all field types"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "name"
|
||||
type = "text"
|
||||
prompt = "Your name"
|
||||
placeholder = "John Doe"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Your email"
|
||||
placeholder = "john@example.com"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "subject"
|
||||
type = "text"
|
||||
prompt = "Subject"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "message"
|
||||
type = "editor"
|
||||
prompt = "Your message"
|
||||
file_extension = "txt"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "priority"
|
||||
type = "select"
|
||||
prompt = "Priority level"
|
||||
options = ["Low", "Medium", "High", "Urgent"]
|
||||
options = [
|
||||
{ value = "Low", label = "Low Priority - Can wait" },
|
||||
{ value = "Medium", label = "Medium Priority - Normal schedule" },
|
||||
{ value = "High", label = "High Priority - Urgent attention" },
|
||||
{ value = "Urgent", label = "Urgent - Critical issue" },
|
||||
]
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "category"
|
||||
type = "multiselect"
|
||||
prompt = "Message categories"
|
||||
options = ["Bug Report", "Feature Request", "Documentation", "Other"]
|
||||
options = [
|
||||
{ value = "Bug Report", label = "🐛 Bug Report" },
|
||||
{ value = "Feature Request", label = "✨ Feature Request" },
|
||||
{ value = "Documentation", label = "📚 Documentation" },
|
||||
{ value = "Other", label = "❓ Other" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "newsletter"
|
||||
type = "confirm"
|
||||
prompt = "Subscribe to updates?"
|
||||
|
||||
@ -2,14 +2,14 @@ name = "Simple Debug Form"
|
||||
description = "Two fields to test form execution flow"
|
||||
locale = "en-US"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "first_name"
|
||||
type = "text"
|
||||
prompt = "First Name"
|
||||
required = true
|
||||
order = 1
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "last_name"
|
||||
type = "text"
|
||||
prompt = "Last Name"
|
||||
|
||||
@ -1,47 +1,57 @@
|
||||
name = "Simple Contact Form"
|
||||
description = "A basic contact form with all field types"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "name"
|
||||
type = "text"
|
||||
prompt = "Your name"
|
||||
placeholder = "John Doe"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Your email"
|
||||
placeholder = "john@example.com"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "subject"
|
||||
type = "text"
|
||||
prompt = "Subject"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "message"
|
||||
type = "editor"
|
||||
prompt = "Your message"
|
||||
file_extension = "txt"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "priority"
|
||||
type = "select"
|
||||
prompt = "Priority level"
|
||||
options = ["Low", "Medium", "High", "Urgent"]
|
||||
options = [
|
||||
{ value = "Low", label = "Low Priority - Can wait" },
|
||||
{ value = "Medium", label = "Medium Priority - Normal schedule" },
|
||||
{ value = "High", label = "High Priority - Urgent attention" },
|
||||
{ value = "Urgent", label = "Urgent - Critical issue" },
|
||||
]
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "category"
|
||||
type = "multiselect"
|
||||
prompt = "Message categories"
|
||||
options = ["Bug Report", "Feature Request", "Documentation", "Other"]
|
||||
options = [
|
||||
{ value = "Bug Report", label = "🐛 Bug Report" },
|
||||
{ value = "Feature Request", label = "✨ Feature Request" },
|
||||
{ value = "Documentation", label = "📚 Documentation" },
|
||||
{ value = "Other", label = "❓ Other" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "newsletter"
|
||||
type = "confirm"
|
||||
prompt = "Subscribe to updates?"
|
||||
default = false
|
||||
default = "false"
|
||||
|
||||
@ -2,7 +2,7 @@ name = "Form with Grouped Display Items"
|
||||
description = "Demonstrates grouping related display items together"
|
||||
|
||||
# Main header (no group - always shown)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "header"
|
||||
title = "✨ Grouped Items Example"
|
||||
@ -11,15 +11,19 @@ border_bottom = true
|
||||
align = "center"
|
||||
|
||||
# Account selection
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "account_type"
|
||||
type = "select"
|
||||
prompt = "Select account type"
|
||||
options = ["Personal", "Premium", "Enterprise"]
|
||||
options = [
|
||||
{ value = "Personal", label = "Personal - Individual Users" },
|
||||
{ value = "Premium", label = "Premium - Growing Teams" },
|
||||
{ value = "Enterprise", label = "Enterprise - Large Organizations" },
|
||||
]
|
||||
required = true
|
||||
|
||||
# PREMIUM GROUP - All these items are grouped together
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_header"
|
||||
type = "section"
|
||||
title = "🌟 Premium Features"
|
||||
@ -27,14 +31,14 @@ group = "premium"
|
||||
when = "account_type == Premium"
|
||||
border_top = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_features"
|
||||
type = "section"
|
||||
content = "✓ Unlimited storage\n✓ Advanced analytics\n✓ Priority support\n✓ Custom branding"
|
||||
group = "premium"
|
||||
when = "account_type == Premium"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_price"
|
||||
type = "section"
|
||||
content = "Pricing: $29/month"
|
||||
@ -42,16 +46,20 @@ group = "premium"
|
||||
when = "account_type == Premium"
|
||||
border_bottom = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "premium_payment"
|
||||
type = "select"
|
||||
prompt = "Payment method"
|
||||
options = ["Credit Card", "Bank Transfer", "PayPal"]
|
||||
options = [
|
||||
{ value = "Credit Card", label = "💳 Credit Card" },
|
||||
{ value = "Bank Transfer", label = "🏦 Bank Transfer" },
|
||||
{ value = "PayPal", label = "🅿️ PayPal" },
|
||||
]
|
||||
when = "account_type == Premium"
|
||||
required = true
|
||||
|
||||
# ENTERPRISE GROUP - All these items grouped together
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_header"
|
||||
type = "section"
|
||||
title = "🏛️ Enterprise Solution"
|
||||
@ -59,21 +67,21 @@ group = "enterprise"
|
||||
when = "account_type == Enterprise"
|
||||
border_top = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_features"
|
||||
type = "section"
|
||||
content = "✓ Unlimited everything\n✓ Dedicated support\n✓ Custom integration\n✓ SLA guarantee\n✓ On-premise option"
|
||||
group = "enterprise"
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_note"
|
||||
type = "section"
|
||||
content = "⚠️ Requires enterprise agreement"
|
||||
group = "enterprise"
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_contact_info"
|
||||
type = "section"
|
||||
content = "Contact our sales team for pricing"
|
||||
@ -81,7 +89,7 @@ group = "enterprise"
|
||||
when = "account_type == Enterprise"
|
||||
border_bottom = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "enterprise_contact_email"
|
||||
type = "text"
|
||||
prompt = "Your email"
|
||||
@ -89,28 +97,32 @@ when = "account_type == Enterprise"
|
||||
required = true
|
||||
|
||||
# SUPPORT GROUP - Organized support options
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_section_header"
|
||||
type = "section"
|
||||
title = "📞 Support Options"
|
||||
group = "support"
|
||||
border_top = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_info"
|
||||
type = "section"
|
||||
content = "Choose your preferred support level"
|
||||
group = "support"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "support_level"
|
||||
type = "select"
|
||||
prompt = "Support Level"
|
||||
options = ["Basic", "Standard", "Premium"]
|
||||
options = [
|
||||
{ value = "Basic", label = "Basic - Email only" },
|
||||
{ value = "Standard", label = "Standard - Email & chat" },
|
||||
{ value = "Premium", label = "Premium - Phone & live support" },
|
||||
]
|
||||
group = "support"
|
||||
required = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_footer"
|
||||
type = "section"
|
||||
content = "Support is available 24/7"
|
||||
@ -118,21 +130,21 @@ group = "support"
|
||||
border_bottom = true
|
||||
|
||||
# FINAL GROUP - Completion items
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "final_header"
|
||||
type = "section"
|
||||
title = "✅ Complete Registration"
|
||||
group = "final"
|
||||
border_top = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_terms"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the terms and conditions"
|
||||
group = "final"
|
||||
required = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "final_cta"
|
||||
type = "cta"
|
||||
title = "Ready?"
|
||||
|
||||
@ -2,7 +2,7 @@ name = "Professional Service Registration"
|
||||
description = "Multi-section registration form with headers and CTAs"
|
||||
|
||||
# Header section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "header"
|
||||
title = "🎯 Professional Services Registration"
|
||||
@ -11,13 +11,13 @@ border_bottom = true
|
||||
align = "center"
|
||||
|
||||
# Welcome section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "welcome"
|
||||
type = "section"
|
||||
content = "Welcome to our professional services platform. Please fill in your information to get started."
|
||||
|
||||
# Contact information section header
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "contact_header"
|
||||
type = "section_header"
|
||||
title = "📋 Contact Information"
|
||||
@ -25,95 +25,114 @@ border_top = true
|
||||
margin_left = 0
|
||||
|
||||
# Contact fields
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "full_name"
|
||||
type = "text"
|
||||
prompt = "Full Name"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Email Address"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "phone"
|
||||
type = "text"
|
||||
prompt = "Phone Number"
|
||||
required = false
|
||||
|
||||
# Services section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "services_header"
|
||||
type = "section_header"
|
||||
title = "🔧 Services Selection"
|
||||
border_top = true
|
||||
margin_left = 0
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "primary_service"
|
||||
type = "select"
|
||||
prompt = "Primary Service Needed"
|
||||
options = ["Consulting", "Development", "Design", "Support"]
|
||||
options = [
|
||||
{ value = "Consulting", label = "💼 Consulting & Strategy" },
|
||||
{ value = "Development", label = "🚀 Development & Engineering" },
|
||||
{ value = "Design", label = "🎨 Design & UX" },
|
||||
{ value = "Support", label = "🛠️ Support & Maintenance" },
|
||||
]
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "additional_services"
|
||||
type = "multiselect"
|
||||
prompt = "Additional Services"
|
||||
options = ["Training", "Documentation", "Maintenance", "Security Audit"]
|
||||
options = [
|
||||
{ value = "Training", label = "📚 Training Programs" },
|
||||
{ value = "Documentation", label = "📖 Documentation" },
|
||||
{ value = "Maintenance", label = "🔧 Maintenance & Support" },
|
||||
{ value = "Security Audit", label = "🔐 Security Audit" },
|
||||
]
|
||||
|
||||
# Preferences section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "prefs_header"
|
||||
type = "section_header"
|
||||
title = "⚙️ Preferences"
|
||||
border_top = true
|
||||
margin_left = 0
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "experience_level"
|
||||
type = "select"
|
||||
prompt = "Your Experience Level"
|
||||
options = ["Beginner", "Intermediate", "Advanced", "Expert"]
|
||||
options = [
|
||||
{ value = "Beginner", label = "Beginner - Just getting started" },
|
||||
{ value = "Intermediate", label = "Intermediate - Some experience" },
|
||||
{ value = "Advanced", label = "Advanced - Deep knowledge" },
|
||||
{ value = "Expert", label = "Expert - Full mastery" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "preferred_contact"
|
||||
type = "select"
|
||||
prompt = "Preferred Contact Method"
|
||||
options = ["Email", "Phone", "Video Call"]
|
||||
options = [
|
||||
{ value = "Email", label = "📧 Email" },
|
||||
{ value = "Phone", label = "📞 Phone" },
|
||||
{ value = "Video Call", label = "🎥 Video Call" },
|
||||
]
|
||||
|
||||
# Agreement section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "agreement_header"
|
||||
type = "section_header"
|
||||
title = "📜 Agreement"
|
||||
border_top = true
|
||||
margin_left = 0
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_terms"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the terms and conditions"
|
||||
default = false
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_privacy"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the privacy policy"
|
||||
default = false
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "marketing_consent"
|
||||
type = "confirm"
|
||||
prompt = "I consent to receive marketing communications"
|
||||
default = false
|
||||
|
||||
# Footer with CTA
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "final_cta"
|
||||
type = "cta"
|
||||
title = "Thank you for your information!"
|
||||
|
||||
@ -2,15 +2,19 @@ name = "User Account Setup"
|
||||
description = "Setup account with conditional fields based on user type"
|
||||
|
||||
# First, select account type
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "account_type"
|
||||
type = "select"
|
||||
prompt = "What type of account do you want?"
|
||||
options = ["Personal", "Business", "Developer"]
|
||||
options = [
|
||||
{ value = "Personal", label = "Personal - Individual use" },
|
||||
{ value = "Business", label = "Business - Company account" },
|
||||
{ value = "Developer", label = "Developer - Technical team" },
|
||||
]
|
||||
required = true
|
||||
|
||||
# Business name is only shown if account_type == Business
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "business_name"
|
||||
type = "text"
|
||||
prompt = "Enter your business name"
|
||||
@ -19,7 +23,7 @@ when = "account_type == Business"
|
||||
required = true
|
||||
|
||||
# Business registration is only needed for Business
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "business_registration"
|
||||
type = "text"
|
||||
prompt = "Business registration number"
|
||||
@ -27,21 +31,27 @@ placeholder = "123-456-789"
|
||||
when = "account_type == Business"
|
||||
|
||||
# Developer specific fields
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "github_username"
|
||||
type = "text"
|
||||
prompt = "GitHub username (optional)"
|
||||
when = "account_type == Developer"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "preferred_language"
|
||||
type = "select"
|
||||
prompt = "Preferred programming language"
|
||||
options = ["Rust", "Python", "Go", "Java", "JavaScript"]
|
||||
options = [
|
||||
{ value = "Rust", label = "🦀 Rust - Systems programming" },
|
||||
{ value = "Python", label = "🐍 Python - Data & scripting" },
|
||||
{ value = "Go", label = "🐹 Go - Cloud native" },
|
||||
{ value = "Java", label = "☕ Java - Enterprise" },
|
||||
{ value = "JavaScript", label = "🟨 JavaScript - Web development" },
|
||||
]
|
||||
when = "account_type == Developer"
|
||||
|
||||
# Email (required for all)
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Email address"
|
||||
@ -49,7 +59,7 @@ placeholder = "user@example.com"
|
||||
required = true
|
||||
|
||||
# Enable 2FA
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "enable_2fa"
|
||||
type = "confirm"
|
||||
prompt = "Enable two-factor authentication?"
|
||||
@ -57,16 +67,20 @@ default = true
|
||||
required = true
|
||||
|
||||
# 2FA method only if enabled
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "2fa_method"
|
||||
type = "select"
|
||||
prompt = "Choose 2FA method"
|
||||
options = ["TOTP", "SMS", "Email"]
|
||||
options = [
|
||||
{ value = "TOTP", label = "🔐 TOTP - Authenticator app" },
|
||||
{ value = "SMS", label = "📱 SMS - Text message" },
|
||||
{ value = "Email", label = "📧 Email" },
|
||||
]
|
||||
when = "enable_2fa == true"
|
||||
required = true
|
||||
|
||||
# Phone number for SMS 2FA
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "phone_number"
|
||||
type = "text"
|
||||
prompt = "Phone number (for SMS 2FA)"
|
||||
@ -75,23 +89,27 @@ when = "2fa_method == SMS"
|
||||
required = true
|
||||
|
||||
# Newsletter subscription
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "subscribe_newsletter"
|
||||
type = "confirm"
|
||||
prompt = "Subscribe to our newsletter?"
|
||||
default = false
|
||||
|
||||
# Newsletter frequency (only if subscribed)
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "newsletter_frequency"
|
||||
type = "select"
|
||||
prompt = "How often would you like to receive newsletters?"
|
||||
options = ["Weekly", "Monthly", "Quarterly"]
|
||||
options = [
|
||||
{ value = "Weekly", label = "📬 Weekly - Every 7 days" },
|
||||
{ value = "Monthly", label = "📅 Monthly - Once per month" },
|
||||
{ value = "Quarterly", label = "📊 Quarterly - Every 3 months" },
|
||||
]
|
||||
when = "subscribe_newsletter == true"
|
||||
required = true
|
||||
|
||||
# Terms and conditions (always required)
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_terms"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the terms and conditions *"
|
||||
|
||||
@ -2,7 +2,7 @@ name = "Dynamic Section Management"
|
||||
description = "Form with sections that appear/disappear based on selections"
|
||||
|
||||
# Main header (always visible)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "header"
|
||||
title = "✨ Dynamic Form with Conditional Sections"
|
||||
@ -11,57 +11,66 @@ border_bottom = true
|
||||
align = "center"
|
||||
|
||||
# Instructions (always visible)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "instructions"
|
||||
type = "section"
|
||||
content = "Select your preferences below. Additional sections will appear based on your choices."
|
||||
margin_left = 2
|
||||
|
||||
# Account type selection
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "account_type"
|
||||
type = "select"
|
||||
prompt = "What type of account do you need?"
|
||||
options = ["Personal", "Business", "Enterprise"]
|
||||
options = [
|
||||
{ value = "Personal", label = "Personal - Individual use" },
|
||||
{ value = "Business", label = "Business - Small to medium teams" },
|
||||
{ value = "Enterprise", label = "Enterprise - Large organizations" },
|
||||
]
|
||||
required = true
|
||||
|
||||
# Business section (only if account_type == Business)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "business_section_header"
|
||||
type = "section"
|
||||
title = "🏢 Business Information"
|
||||
border_top = true
|
||||
when = "account_type == Business"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "company_name"
|
||||
type = "text"
|
||||
prompt = "Company Name"
|
||||
when = "account_type == Business"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "company_size"
|
||||
type = "select"
|
||||
prompt = "Company Size"
|
||||
options = ["1-10", "11-50", "51-200", "200+"]
|
||||
options = [
|
||||
{ value = "1-10", label = "1-10 - Startup" },
|
||||
{ value = "11-50", label = "11-50 - Small business" },
|
||||
{ value = "51-200", label = "51-200 - Growth stage" },
|
||||
{ value = "200+", label = "200+ - Enterprise scale" },
|
||||
]
|
||||
when = "account_type == Business"
|
||||
|
||||
# Enterprise section (only if account_type == Enterprise)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_section_header"
|
||||
type = "section"
|
||||
title = "🏛️ Enterprise Setup"
|
||||
border_top = true
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_warning"
|
||||
type = "section"
|
||||
content = "⚠️ Enterprise accounts require additional verification and support setup."
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "enterprise_contact"
|
||||
type = "text"
|
||||
prompt = "Enterprise Account Manager Email"
|
||||
@ -69,51 +78,65 @@ when = "account_type == Enterprise"
|
||||
required = true
|
||||
|
||||
# Infrastructure selection (visible for Business & Enterprise)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "infrastructure_header"
|
||||
type = "section"
|
||||
title = "🔧 Infrastructure Preferences"
|
||||
border_top = true
|
||||
when = "account_type == Business"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "infrastructure_header_enterprise"
|
||||
type = "section"
|
||||
title = "🔧 Infrastructure Preferences"
|
||||
border_top = true
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "hosting_preference"
|
||||
type = "select"
|
||||
prompt = "Preferred Hosting"
|
||||
options = ["Cloud", "On-Premise", "Hybrid"]
|
||||
options = [
|
||||
{ value = "Cloud", label = "☁️ Cloud - AWS/Azure/GCP" },
|
||||
{ value = "On-Premise", label = "🏢 On-Premise - Your data center" },
|
||||
{ value = "Hybrid", label = "🔀 Hybrid - Mix of both" },
|
||||
]
|
||||
when = "account_type == Business"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "hosting_preference_enterprise"
|
||||
type = "select"
|
||||
prompt = "Preferred Hosting"
|
||||
options = ["Cloud", "On-Premise", "Hybrid", "Multi-Cloud"]
|
||||
options = [
|
||||
{ value = "Cloud", label = "☁️ Cloud - AWS/Azure/GCP" },
|
||||
{ value = "On-Premise", label = "🏢 On-Premise - Your data center" },
|
||||
{ value = "Hybrid", label = "🔀 Hybrid - Mix of both" },
|
||||
{ value = "Multi-Cloud", label = "🌐 Multi-Cloud - Multiple providers" },
|
||||
]
|
||||
when = "account_type == Enterprise"
|
||||
|
||||
# Support level selection
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_header"
|
||||
type = "section"
|
||||
title = "💬 Support Options"
|
||||
border_top = true
|
||||
margin_left = 0
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "support_level"
|
||||
type = "select"
|
||||
prompt = "Support Level"
|
||||
options = ["Community", "Basic", "Premium", "Enterprise"]
|
||||
options = [
|
||||
{ value = "Community", label = "👥 Community - Free community support" },
|
||||
{ value = "Basic", label = "🛠️ Basic - Email support" },
|
||||
{ value = "Premium", label = "⭐ Premium - 24/7 phone & email" },
|
||||
{ value = "Enterprise", label = "🏛️ Enterprise - Dedicated team" },
|
||||
]
|
||||
required = true
|
||||
|
||||
# Premium support details (only if support_level == Premium)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_support_info"
|
||||
type = "section"
|
||||
title = "⭐ Premium Support Includes"
|
||||
@ -122,7 +145,7 @@ border_top = true
|
||||
when = "support_level == Premium"
|
||||
|
||||
# Enterprise support details (only if support_level == Enterprise)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_support_info"
|
||||
type = "section"
|
||||
title = "⭐⭐ Enterprise Support Includes"
|
||||
@ -130,14 +153,14 @@ content = "✓ 24/7 Dedicated Support Line\n✓ Dedicated Technical Team\n✓ Cu
|
||||
border_top = true
|
||||
when = "support_level == Enterprise"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "support_email"
|
||||
type = "text"
|
||||
prompt = "Support Contact Email"
|
||||
when = "support_level == Premium"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "support_email_enterprise"
|
||||
type = "text"
|
||||
prompt = "Support Contact Email"
|
||||
@ -145,7 +168,7 @@ when = "support_level == Enterprise"
|
||||
required = true
|
||||
|
||||
# Final section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "final_section"
|
||||
type = "section"
|
||||
title = "✅ Ready to Complete"
|
||||
@ -154,7 +177,7 @@ border_top = true
|
||||
border_bottom = true
|
||||
align = "center"
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_terms"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the terms and conditions"
|
||||
|
||||
@ -2,13 +2,13 @@ name = "Display Items Showcase"
|
||||
description = "Demonstrates all display item types and attributes"
|
||||
|
||||
# Basic Header
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "header_basic"
|
||||
type = "header"
|
||||
title = "Basic Header"
|
||||
|
||||
# Header with borders
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "header_bordered"
|
||||
type = "header"
|
||||
title = "Header with Borders"
|
||||
@ -16,7 +16,7 @@ border_top = true
|
||||
border_bottom = true
|
||||
|
||||
# Header centered
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "header_centered"
|
||||
type = "header"
|
||||
title = "Centered Header"
|
||||
@ -25,13 +25,13 @@ border_bottom = true
|
||||
align = "center"
|
||||
|
||||
# Simple section with content
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "info_section"
|
||||
type = "section"
|
||||
content = "This is a simple information section. It contains text that guides the user."
|
||||
|
||||
# Section with borders
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "important_info"
|
||||
type = "section"
|
||||
title = "Important Information"
|
||||
@ -39,7 +39,7 @@ content = "This section has both title and content with a border on top."
|
||||
border_top = true
|
||||
|
||||
# Multi-line content section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "multiline_section"
|
||||
type = "section"
|
||||
title = "Features"
|
||||
@ -47,20 +47,20 @@ content = "✓ Feature One\n✓ Feature Two\n✓ Feature Three\n✓ Feature Four
|
||||
border_bottom = true
|
||||
|
||||
# Example field
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "example_field"
|
||||
type = "text"
|
||||
prompt = "Enter something"
|
||||
|
||||
# Left-aligned section
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "instructions"
|
||||
type = "section"
|
||||
content = "Please follow the instructions above."
|
||||
margin_left = 2
|
||||
|
||||
# Call To Action
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "cta_submit"
|
||||
type = "cta"
|
||||
title = "Ready?"
|
||||
@ -70,7 +70,7 @@ border_bottom = true
|
||||
align = "center"
|
||||
|
||||
# Right-aligned footer
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "footer"
|
||||
type = "footer"
|
||||
content = "© 2024 Your Company. All rights reserved."
|
||||
|
||||
@ -2,21 +2,21 @@ name = "Custom Border Demo Form"
|
||||
description = "Demonstrates custom border_top_char, border_top_len, border_bottom_char, border_bottom_len"
|
||||
|
||||
# Standard border with default ═
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "header_group"
|
||||
type = "group"
|
||||
order = 1
|
||||
includes = ["fragments/header.toml"]
|
||||
|
||||
# Custom border with different top and bottom styles
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "custom_group"
|
||||
type = "group"
|
||||
order = 2
|
||||
includes = ["fragments/custom_border_section.toml"]
|
||||
|
||||
# Simple field
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "project_name"
|
||||
type = "text"
|
||||
prompt = "Project name"
|
||||
@ -24,7 +24,7 @@ required = true
|
||||
order = 3
|
||||
|
||||
# Different border styles with corners and margin
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "footer"
|
||||
type = "section"
|
||||
title = "✓ Complete!"
|
||||
|
||||
@ -2,7 +2,7 @@ name = "Fancy Borders Demo"
|
||||
description = "Demonstrates custom corner characters with fancy Unicode borders"
|
||||
|
||||
# Fancy bordered header - border at margin 0, content at margin 2
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "fancy_header"
|
||||
type = "section"
|
||||
title = "✨ Welcome to Fancy Forms ✨"
|
||||
@ -21,23 +21,27 @@ border_bottom_len = 35
|
||||
border_bottom_r = "╯"
|
||||
|
||||
# Include fancy border fragment - margin settings are in the fragment items
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "fancy_group"
|
||||
type = "group"
|
||||
order = 2
|
||||
includes = ["fragments/fancy_border_section.toml"]
|
||||
|
||||
# Simple field
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "favorite_style"
|
||||
type = "select"
|
||||
prompt = "Your favorite border style"
|
||||
options = ["Fancy Unicode", "Simple ASCII", "Mixed Styles"]
|
||||
options = [
|
||||
{ value = "Fancy Unicode", label = "✨ Fancy Unicode - Modern look" },
|
||||
{ value = "Simple ASCII", label = "📝 Simple ASCII - Classic style" },
|
||||
{ value = "Mixed Styles", label = "🎨 Mixed Styles - Custom borders" },
|
||||
]
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
# Box border for footer - border at 0, content at 2
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "box_footer"
|
||||
type = "section"
|
||||
title = "✓ All Done!"
|
||||
|
||||
@ -69,7 +69,13 @@ margin_left = 2
|
||||
name = "overall_satisfaction"
|
||||
type = "select"
|
||||
prompt = "Overall satisfaction with product"
|
||||
options = ["Very Unsatisfied", "Unsatisfied", "Neutral", "Satisfied", "Very Satisfied"]
|
||||
options = [
|
||||
{ value = "Very Unsatisfied", label = "😢 Very Unsatisfied" },
|
||||
{ value = "Unsatisfied", label = "😟 Unsatisfied" },
|
||||
{ value = "Neutral", label = "😐 Neutral" },
|
||||
{ value = "Satisfied", label = "😊 Satisfied" },
|
||||
{ value = "Very Satisfied", label = "😍 Very Satisfied" },
|
||||
]
|
||||
required = true
|
||||
group = "product"
|
||||
order = 7
|
||||
@ -78,7 +84,13 @@ order = 7
|
||||
name = "usage_frequency"
|
||||
type = "select"
|
||||
prompt = "How often do you use this product?"
|
||||
options = ["Daily", "Weekly", "Monthly", "Occasionally", "Never"]
|
||||
options = [
|
||||
{ value = "Daily", label = "📅 Daily" },
|
||||
{ value = "Weekly", label = "📊 Weekly" },
|
||||
{ value = "Monthly", label = "📆 Monthly" },
|
||||
{ value = "Occasionally", label = "📝 Occasionally" },
|
||||
{ value = "Never", label = "🚫 Never" },
|
||||
]
|
||||
required = true
|
||||
group = "product"
|
||||
order = 8
|
||||
@ -87,7 +99,15 @@ order = 8
|
||||
name = "features_used"
|
||||
type = "multiselect"
|
||||
prompt = "Which features do you use? (select all that apply)"
|
||||
options = ["Dashboard", "Analytics", "Reporting", "API Integration", "Mobile App", "Notifications", "Collaboration Tools"]
|
||||
options = [
|
||||
{ value = "Dashboard", label = "📊 Dashboard" },
|
||||
{ value = "Analytics", label = "📈 Analytics" },
|
||||
{ value = "Reporting", label = "📑 Reporting" },
|
||||
{ value = "API Integration", label = "🔌 API Integration" },
|
||||
{ value = "Mobile App", label = "📱 Mobile App" },
|
||||
{ value = "Notifications", label = "🔔 Notifications" },
|
||||
{ value = "Collaboration Tools", label = "👥 Collaboration Tools" },
|
||||
]
|
||||
page_size = 5
|
||||
vim_mode = true
|
||||
group = "product"
|
||||
@ -115,13 +135,13 @@ name = "biggest_pain_point"
|
||||
type = "select"
|
||||
prompt = "What's your biggest pain point?"
|
||||
options = [
|
||||
"Performance issues",
|
||||
"Confusing UI/UX",
|
||||
"Missing features",
|
||||
"Documentation",
|
||||
"Customer support",
|
||||
"Pricing",
|
||||
"Other"
|
||||
{ value = "Performance issues", label = "⚡ Performance issues" },
|
||||
{ value = "Confusing UI/UX", label = "🎨 Confusing UI/UX" },
|
||||
{ value = "Missing features", label = "❌ Missing features" },
|
||||
{ value = "Documentation", label = "📖 Documentation" },
|
||||
{ value = "Customer support", label = "🆘 Customer support" },
|
||||
{ value = "Pricing", label = "💰 Pricing" },
|
||||
{ value = "Other", label = "❓ Other" },
|
||||
]
|
||||
required = true
|
||||
group = "feedback"
|
||||
@ -139,7 +159,13 @@ margin_left = 2
|
||||
name = "contact_preference"
|
||||
type = "select"
|
||||
prompt = "Preferred contact method"
|
||||
options = ["Email", "Phone", "SMS", "In-app notification", "No contact"]
|
||||
options = [
|
||||
{ value = "Email", label = "📧 Email" },
|
||||
{ value = "Phone", label = "📞 Phone" },
|
||||
{ value = "SMS", label = "💬 SMS" },
|
||||
{ value = "In-app notification", label = "🔔 In-app notification" },
|
||||
{ value = "No contact", label = "🚫 No contact" },
|
||||
]
|
||||
default = "Email"
|
||||
required = true
|
||||
group = "preferences"
|
||||
@ -166,7 +192,13 @@ order = 16
|
||||
name = "device_types"
|
||||
type = "multiselect"
|
||||
prompt = "Which devices do you use? (optional)"
|
||||
options = ["Desktop", "Laptop", "Tablet", "Mobile", "Smartwatch"]
|
||||
options = [
|
||||
{ value = "Desktop", label = "🖥️ Desktop" },
|
||||
{ value = "Laptop", label = "💻 Laptop" },
|
||||
{ value = "Tablet", label = "📱 Tablet" },
|
||||
{ value = "Mobile", label = "📲 Mobile Phone" },
|
||||
{ value = "Smartwatch", label = "⌚ Smartwatch" },
|
||||
]
|
||||
when = "beta_features == true"
|
||||
page_size = 4
|
||||
vim_mode = true
|
||||
|
||||
@ -105,7 +105,11 @@ order = 20
|
||||
name = "account_type"
|
||||
type = "select"
|
||||
prompt = "Account Type"
|
||||
options = ["Personal", "Business", "Organization"]
|
||||
options = [
|
||||
{ value = "Personal", label = "👤 Personal - Individual use" },
|
||||
{ value = "Business", label = "💼 Business - Small team" },
|
||||
{ value = "Organization", label = "🏢 Organization - Large team" },
|
||||
]
|
||||
default = "Personal"
|
||||
required = true
|
||||
group = "settings"
|
||||
@ -115,7 +119,11 @@ order = 21
|
||||
name = "subscription_plan"
|
||||
type = "select"
|
||||
prompt = "Subscription Plan"
|
||||
options = ["Free", "Pro", "Enterprise"]
|
||||
options = [
|
||||
{ value = "Free", label = "🆓 Free - Essential features" },
|
||||
{ value = "Pro", label = "⭐ Pro - Advanced features" },
|
||||
{ value = "Enterprise", label = "🏛️ Enterprise - Full suite" },
|
||||
]
|
||||
default = "Free"
|
||||
required = true
|
||||
group = "settings"
|
||||
@ -188,7 +196,13 @@ order = 32
|
||||
name = "notification_frequency"
|
||||
type = "select"
|
||||
prompt = "Email notification frequency"
|
||||
options = ["Immediate", "Daily Digest", "Weekly Summary", "Monthly Summary", "Never"]
|
||||
options = [
|
||||
{ value = "Immediate", label = "⚡ Immediate" },
|
||||
{ value = "Daily Digest", label = "📅 Daily Digest" },
|
||||
{ value = "Weekly Summary", label = "📊 Weekly Summary" },
|
||||
{ value = "Monthly Summary", label = "📈 Monthly Summary" },
|
||||
{ value = "Never", label = "🚫 Never" },
|
||||
]
|
||||
when = "notifications_email == true"
|
||||
default = "Daily Digest"
|
||||
group = "preferences"
|
||||
@ -199,12 +213,12 @@ name = "interests"
|
||||
type = "multiselect"
|
||||
prompt = "Topics you're interested in:"
|
||||
options = [
|
||||
"Product Updates",
|
||||
"Security Alerts",
|
||||
"Performance Tips",
|
||||
"Community News",
|
||||
"Educational Content",
|
||||
"Exclusive Offers"
|
||||
{ value = "Product Updates", label = "🚀 Product Updates" },
|
||||
{ value = "Security Alerts", label = "🔒 Security Alerts" },
|
||||
{ value = "Performance Tips", label = "⚡ Performance Tips" },
|
||||
{ value = "Community News", label = "👥 Community News" },
|
||||
{ value = "Educational Content", label = "📚 Educational Content" },
|
||||
{ value = "Exclusive Offers", label = "🎁 Exclusive Offers" },
|
||||
]
|
||||
page_size = 4
|
||||
group = "preferences"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "agreement_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "agreement_header"
|
||||
type = "section"
|
||||
title = "✅ Terms & Conditions"
|
||||
@ -8,35 +8,35 @@ border_top = true
|
||||
order = 1
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "agreement_info"
|
||||
type = "section"
|
||||
content = "Please review and agree to our terms before proceeding"
|
||||
order = 2
|
||||
margin_left = 2
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_terms"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the terms and conditions"
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_privacy"
|
||||
type = "confirm"
|
||||
prompt = "I agree to the privacy policy"
|
||||
required = true
|
||||
order = 4
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "agree_marketing"
|
||||
type = "confirm"
|
||||
prompt = "I consent to receive marketing communications"
|
||||
default = "false"
|
||||
order = 5
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "agreement_footer"
|
||||
type = "section"
|
||||
content = "Click submit to complete your registration"
|
||||
|
||||
98
examples/05-fragments/array-trackers.toml
Normal file
98
examples/05-fragments/array-trackers.toml
Normal file
@ -0,0 +1,98 @@
|
||||
name = "Tracker Configuration with Arrays"
|
||||
description = "Example showing RepeatingGroup arrays for multiple trackers"
|
||||
display_mode = "complete"
|
||||
|
||||
# Header
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "section_header"
|
||||
title = "🎯 Tracker Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
# Introduction
|
||||
[[elements]]
|
||||
name = "intro"
|
||||
type = "section"
|
||||
content = "Configure multiple UDP and HTTP tracker listeners. You can add, edit, or delete trackers as needed."
|
||||
|
||||
# Tracker mode selection
|
||||
[[elements]]
|
||||
name = "tracker_mode"
|
||||
type = "select"
|
||||
prompt = "Tracker Mode"
|
||||
required = true
|
||||
options = [
|
||||
{ value = "public", label = "Public Tracker" },
|
||||
{ value = "private", label = "Private Tracker" },
|
||||
]
|
||||
default = "public"
|
||||
order = 1
|
||||
|
||||
# UDP Trackers array
|
||||
[[elements]]
|
||||
name = "udp_trackers"
|
||||
type = "repeatinggroup"
|
||||
prompt = "UDP Tracker Listeners"
|
||||
fragment = "fragments/tracker-udp-item.toml"
|
||||
min_items = 0
|
||||
max_items = 10
|
||||
default_items = 1
|
||||
unique = true
|
||||
required = false
|
||||
help = "Add UDP tracker listener addresses (must be unique). Standard BitTorrent port is 6969."
|
||||
order = 2
|
||||
|
||||
# HTTP Trackers array
|
||||
[[elements]]
|
||||
name = "http_trackers"
|
||||
type = "repeatinggroup"
|
||||
prompt = "HTTP Tracker Listeners"
|
||||
fragment = "fragments/tracker-http-item.toml"
|
||||
min_items = 0
|
||||
max_items = 10
|
||||
default_items = 1
|
||||
unique = true
|
||||
required = false
|
||||
help = "Add HTTP tracker listener addresses (must be unique). Standard HTTP port is 80, HTTPS is 443."
|
||||
order = 3
|
||||
|
||||
# API Configuration section
|
||||
[[elements]]
|
||||
name = "api_header"
|
||||
type = "section_header"
|
||||
title = "📡 API Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
# API Token
|
||||
[[elements]]
|
||||
name = "api_token"
|
||||
type = "password"
|
||||
prompt = "Admin API Token"
|
||||
required = true
|
||||
help = "Secure token for API authentication"
|
||||
order = 4
|
||||
|
||||
# API Port
|
||||
[[elements]]
|
||||
name = "api_port"
|
||||
type = "text"
|
||||
prompt = "API Port"
|
||||
placeholder = "1212"
|
||||
default = "1212"
|
||||
required = true
|
||||
order = 5
|
||||
|
||||
# Summary
|
||||
[[elements]]
|
||||
name = "summary_header"
|
||||
type = "section_header"
|
||||
title = "✅ Configuration Summary"
|
||||
border_top = true
|
||||
|
||||
[[elements]]
|
||||
name = "summary"
|
||||
type = "section"
|
||||
content = "Review your tracker configuration above. Click submit to save settings."
|
||||
order = 6
|
||||
@ -1,6 +1,6 @@
|
||||
name = "custom_border_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "custom_section_header"
|
||||
type = "section"
|
||||
title = "🎨 Custom Border Styles"
|
||||
@ -9,13 +9,13 @@ order = 1
|
||||
border_top_char = "-"
|
||||
border_top_len = 50
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "custom_info"
|
||||
type = "section"
|
||||
content = "Top border uses '-' (50 chars), bottom border uses '*' (40 chars)"
|
||||
order = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "custom_section_footer"
|
||||
type = "section"
|
||||
content = "You can have different styles for top and bottom borders!"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "employee_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "employee_header"
|
||||
type = "section"
|
||||
title = "👤 Employee Information"
|
||||
@ -8,43 +8,49 @@ border_top = true
|
||||
order = 1
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "employee_info"
|
||||
type = "section"
|
||||
content = "Please provide your employment details"
|
||||
order = 2
|
||||
margin_left = 2
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "employee_name"
|
||||
type = "text"
|
||||
prompt = "Full Name"
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "employee_email"
|
||||
type = "text"
|
||||
prompt = "Work Email"
|
||||
required = true
|
||||
order = 4
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "employee_department"
|
||||
type = "select"
|
||||
prompt = "Department"
|
||||
options = ["Engineering", "Sales", "Marketing", "HR", "Finance"]
|
||||
options = [
|
||||
{ value = "Engineering", label = "💻 Engineering" },
|
||||
{ value = "Sales", label = "💼 Sales" },
|
||||
{ value = "Marketing", label = "📢 Marketing" },
|
||||
{ value = "HR", label = "👥 Human Resources" },
|
||||
{ value = "Finance", label = "💰 Finance" },
|
||||
]
|
||||
required = true
|
||||
order = 5
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "employee_start_date"
|
||||
type = "date"
|
||||
prompt = "Start Date"
|
||||
required = true
|
||||
order = 6
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "employee_footer"
|
||||
type = "section"
|
||||
content = "Please ensure all information is accurate"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "enterprise_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_header"
|
||||
type = "section"
|
||||
title = "🏛️ Enterprise Solution"
|
||||
@ -8,14 +8,14 @@ border_top = true
|
||||
order = 1
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_warning"
|
||||
type = "section"
|
||||
content = "⚠️ Requires enterprise agreement and custom setup"
|
||||
order = 2
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_features"
|
||||
type = "section"
|
||||
content = "✓ Unlimited everything\n✓ Dedicated support team\n✓ Custom integration\n✓ SLA guarantee\n✓ On-premise option\n✓ 24/7 phone support"
|
||||
@ -23,14 +23,14 @@ border_bottom = true
|
||||
order = 3
|
||||
margin_left = 2
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "enterprise_contact_name"
|
||||
type = "text"
|
||||
prompt = "Enterprise contact person"
|
||||
required = true
|
||||
order = 4
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "enterprise_contact_email"
|
||||
type = "text"
|
||||
prompt = "Contact email"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "fancy_border_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "fancy_header"
|
||||
type = "section"
|
||||
title = "╔════════ FANCY BORDER ════════╗"
|
||||
@ -13,14 +13,14 @@ border_top_char = "═"
|
||||
border_top_len = 35
|
||||
border_top_r = "╗"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "fancy_content"
|
||||
type = "section"
|
||||
content = "This demonstrates fancy corner characters and borders"
|
||||
order = 2
|
||||
margin_left = 4
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "fancy_footer"
|
||||
type = "section"
|
||||
content = "You can create beautiful box designs with Unicode!"
|
||||
|
||||
@ -2,23 +2,27 @@ name = "Modular Form with Groups & Includes"
|
||||
description = "Compose form from reusable fragment files"
|
||||
|
||||
# Include main header from fragment (paths relative to this file)
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "main_group"
|
||||
type = "group"
|
||||
order = 1
|
||||
includes = ["fragments/header.toml"]
|
||||
|
||||
# Account type selection
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "account_plan"
|
||||
type = "select"
|
||||
prompt = "Select your plan"
|
||||
options = ["Personal", "Premium", "Enterprise"]
|
||||
options = [
|
||||
{ value = "Personal", label = "Personal - Individual Users" },
|
||||
{ value = "Premium", label = "Premium - Growing Teams" },
|
||||
{ value = "Enterprise", label = "Enterprise - Large Organizations" },
|
||||
]
|
||||
required = true
|
||||
order = 2
|
||||
|
||||
# Premium section - conditionally loaded from fragment
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_group"
|
||||
type = "group"
|
||||
order = 3
|
||||
@ -26,7 +30,7 @@ when = "account_plan == Premium"
|
||||
includes = ["fragments/premium_section.toml"]
|
||||
|
||||
# Enterprise section - conditionally loaded from fragment
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "enterprise_group"
|
||||
type = "group"
|
||||
order = 4
|
||||
@ -34,14 +38,14 @@ when = "account_plan == Enterprise"
|
||||
includes = ["fragments/enterprise_section.toml"]
|
||||
|
||||
# Support section - always included from fragment
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_group"
|
||||
type = "group"
|
||||
order = 5
|
||||
includes = ["fragments/support_section.toml"]
|
||||
|
||||
# Agreement section - always included from fragment
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "agreement_group"
|
||||
type = "group"
|
||||
order = 6
|
||||
|
||||
35
examples/05-fragments/fragments/tracker-http-item.toml
Normal file
35
examples/05-fragments/fragments/tracker-http-item.toml
Normal file
@ -0,0 +1,35 @@
|
||||
name = "tracker_http_item"
|
||||
description = "HTTP Tracker listener configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "bind_address"
|
||||
type = "text"
|
||||
prompt = "HTTP Bind Address"
|
||||
placeholder = "0.0.0.0:7070"
|
||||
default = "0.0.0.0:7070"
|
||||
help = "Format: <IP>:<PORT>. Standard port is 80 for HTTP, 443 for HTTPS"
|
||||
required = true
|
||||
order = 1
|
||||
|
||||
[[elements]]
|
||||
name = "protocol"
|
||||
type = "select"
|
||||
prompt = "Protocol"
|
||||
default = "http"
|
||||
options = [
|
||||
{ value = "http", label = "HTTP (unencrypted)" },
|
||||
{ value = "https", label = "HTTPS (encrypted)" },
|
||||
]
|
||||
required = true
|
||||
order = 2
|
||||
|
||||
[[elements]]
|
||||
name = "workers"
|
||||
type = "text"
|
||||
prompt = "Worker Threads"
|
||||
placeholder = "8"
|
||||
default = "8"
|
||||
help = "Number of concurrent worker threads"
|
||||
required = false
|
||||
order = 3
|
||||
23
examples/05-fragments/fragments/tracker-udp-item.toml
Normal file
23
examples/05-fragments/fragments/tracker-udp-item.toml
Normal file
@ -0,0 +1,23 @@
|
||||
name = "tracker_udp_item"
|
||||
description = "UDP Tracker listener configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "bind_address"
|
||||
type = "text"
|
||||
prompt = "UDP Bind Address"
|
||||
placeholder = "0.0.0.0:6969"
|
||||
default = "0.0.0.0:6969"
|
||||
help = "Format: <IP>:<PORT>. Use 0.0.0.0 to listen on all interfaces"
|
||||
required = true
|
||||
order = 1
|
||||
|
||||
[[elements]]
|
||||
name = "workers"
|
||||
type = "text"
|
||||
prompt = "Worker Threads"
|
||||
placeholder = "4"
|
||||
default = "4"
|
||||
help = "Number of concurrent worker threads"
|
||||
required = false
|
||||
order = 2
|
||||
@ -1,6 +1,6 @@
|
||||
name = "header_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "header"
|
||||
title = "✨ Form with Modular Sections"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "premium_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_header"
|
||||
type = "section"
|
||||
title = "🌟 Premium Features"
|
||||
@ -8,7 +8,7 @@ border_top = true
|
||||
order = 1
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "premium_features"
|
||||
type = "section"
|
||||
content = "✓ Unlimited storage\n✓ Advanced analytics\n✓ Priority support\n✓ Custom branding\n✓ API access"
|
||||
@ -16,10 +16,14 @@ border_bottom = true
|
||||
order = 2
|
||||
margin_left = 2
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "premium_payment_method"
|
||||
type = "select"
|
||||
prompt = "Payment method"
|
||||
options = ["Credit Card", "Bank Transfer", "PayPal"]
|
||||
options = [
|
||||
{ value = "Credit Card", label = "💳 Credit Card" },
|
||||
{ value = "Bank Transfer", label = "🏦 Bank Transfer" },
|
||||
{ value = "PayPal", label = "🅿️ PayPal" },
|
||||
]
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
name = "support_fragment"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_header"
|
||||
type = "section"
|
||||
title = "📞 Support Options"
|
||||
@ -8,22 +8,26 @@ border_top = true
|
||||
order = 1
|
||||
margin_left = 2
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_info"
|
||||
type = "section"
|
||||
content = "Choose your preferred support level"
|
||||
order = 2
|
||||
margin_left = 2
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "support_level"
|
||||
type = "select"
|
||||
prompt = "Support Level"
|
||||
options = ["Basic", "Standard", "Premium"]
|
||||
options = [
|
||||
{ value = "Basic", label = "Basic - Email only" },
|
||||
{ value = "Standard", label = "Standard - Email & chat" },
|
||||
{ value = "Premium", label = "Premium - Phone & live support" },
|
||||
]
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "support_footer"
|
||||
type = "section"
|
||||
content = "Support is available 24/7 for Premium plans"
|
||||
|
||||
@ -35,7 +35,11 @@ required = true
|
||||
name = "role"
|
||||
type = "select"
|
||||
prompt = "forms.registration.role-prompt"
|
||||
options = ["forms.registration.roles.admin", "forms.registration.roles.user", "forms.registration.roles.guest"]
|
||||
options = [
|
||||
{ value = "admin", label = "forms.registration.roles.admin" },
|
||||
{ value = "user", label = "forms.registration.roles.user" },
|
||||
{ value = "guest", label = "forms.registration.roles.guest" },
|
||||
]
|
||||
i18n = true
|
||||
required = true
|
||||
|
||||
|
||||
@ -13,6 +13,10 @@ i18n = true
|
||||
name = "role"
|
||||
type = "select"
|
||||
prompt = "role-prompt"
|
||||
options = ["role-admin", "role-user", "role-guest"]
|
||||
options = [
|
||||
{ value = "admin", label = "role-admin" },
|
||||
{ value = "user", label = "role-user" },
|
||||
{ value = "guest", label = "role-guest" },
|
||||
]
|
||||
i18n = true
|
||||
default = "role-user"
|
||||
default = "user"
|
||||
|
||||
198
examples/07-nickel-generation/arrays-form.toml
Normal file
198
examples/07-nickel-generation/arrays-form.toml
Normal file
@ -0,0 +1,198 @@
|
||||
name = "Tracker Configuration with Arrays"
|
||||
description = "Complete example showing RepeatingGroup arrays in action with Nickel schema integration"
|
||||
display_mode = "complete"
|
||||
|
||||
# Header
|
||||
[[elements]]
|
||||
name = "main_header"
|
||||
type = "section_header"
|
||||
title = "🎯 Tracker & API Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "intro"
|
||||
type = "section"
|
||||
content = "Configure tracker listeners and API endpoints. You can add multiple UDP/HTTP listeners and manage users and API endpoints dynamically."
|
||||
|
||||
# ========================================================
|
||||
# TRACKER CONFIGURATION
|
||||
# ========================================================
|
||||
|
||||
[[elements]]
|
||||
name = "tracker_section_header"
|
||||
type = "section_header"
|
||||
title = "📡 Tracker Configuration"
|
||||
border_top = true
|
||||
|
||||
[[elements]]
|
||||
name = "tracker_mode"
|
||||
type = "select"
|
||||
prompt = "Tracker Mode"
|
||||
required = true
|
||||
options = [
|
||||
{ value = "public", label = "Public Tracker (anyone can use)" },
|
||||
{ value = "private", label = "Private Tracker (registered users only)" },
|
||||
]
|
||||
default = "public"
|
||||
order = 1
|
||||
|
||||
# UDP Trackers - Array of listeners
|
||||
[[elements]]
|
||||
name = "udp_trackers"
|
||||
type = "repeatinggroup"
|
||||
prompt = "UDP Tracker Listeners"
|
||||
fragment = "fragments/tracker-udp-item.toml"
|
||||
min_items = 0
|
||||
max_items = 10
|
||||
default_items = 1
|
||||
unique = true
|
||||
required = false
|
||||
help = "Add UDP tracker listener addresses (must be unique). Standard BitTorrent port is 6969."
|
||||
nickel_path = ["udp_trackers"]
|
||||
order = 2
|
||||
|
||||
# HTTP Trackers - Array of listeners
|
||||
[[elements]]
|
||||
name = "http_trackers"
|
||||
type = "repeatinggroup"
|
||||
prompt = "HTTP Tracker Listeners"
|
||||
fragment = "fragments/tracker-http-item.toml"
|
||||
min_items = 0
|
||||
max_items = 10
|
||||
default_items = 1
|
||||
unique = true
|
||||
required = false
|
||||
help = "Add HTTP tracker listener addresses (must be unique). Standard ports: 80 (HTTP), 443 (HTTPS)"
|
||||
nickel_path = ["http_trackers"]
|
||||
order = 3
|
||||
|
||||
# ========================================================
|
||||
# API CONFIGURATION
|
||||
# ========================================================
|
||||
|
||||
[[elements]]
|
||||
name = "api_section_header"
|
||||
type = "section_header"
|
||||
title = "📡 API Configuration"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "api_token"
|
||||
type = "password"
|
||||
prompt = "Admin API Token"
|
||||
required = true
|
||||
help = "Secure token for API authentication and access control"
|
||||
nickel_path = ["api_token"]
|
||||
order = 4
|
||||
|
||||
[[elements]]
|
||||
name = "api_port"
|
||||
type = "text"
|
||||
prompt = "API Port"
|
||||
placeholder = "1212"
|
||||
default = "1212"
|
||||
required = true
|
||||
help = "Port number for API server (1024-65535). Standard: 1212"
|
||||
nickel_path = ["api_port"]
|
||||
order = 5
|
||||
|
||||
# API Endpoints - Array of exposed endpoints
|
||||
[[elements]]
|
||||
name = "api_endpoints"
|
||||
type = "repeatinggroup"
|
||||
prompt = "API Endpoints"
|
||||
fragment = "fragments/api-endpoint-item.toml"
|
||||
min_items = 0
|
||||
max_items = 20
|
||||
default_items = 0
|
||||
unique = true
|
||||
required = false
|
||||
help = "Define custom API endpoints (must be unique). Each endpoint path must be different."
|
||||
nickel_path = ["api_endpoints"]
|
||||
order = 6
|
||||
|
||||
# ========================================================
|
||||
# USER MANAGEMENT
|
||||
# ========================================================
|
||||
|
||||
[[elements]]
|
||||
name = "users_section_header"
|
||||
type = "section_header"
|
||||
title = "👥 User Management"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "users"
|
||||
type = "repeatinggroup"
|
||||
prompt = "User Accounts"
|
||||
fragment = "fragments/user-item.toml"
|
||||
min_items = 0
|
||||
max_items = 100
|
||||
default_items = 0
|
||||
unique = true
|
||||
required = false
|
||||
help = "Add user accounts (must be unique). Each username must be different."
|
||||
nickel_path = ["users"]
|
||||
order = 7
|
||||
|
||||
# ========================================================
|
||||
# OPTIONAL FEATURES
|
||||
# ========================================================
|
||||
|
||||
[[elements]]
|
||||
name = "features_section_header"
|
||||
type = "section_header"
|
||||
title = "⚙️ Optional Features"
|
||||
border_top = true
|
||||
border_bottom = true
|
||||
|
||||
[[elements]]
|
||||
name = "enable_metrics"
|
||||
type = "confirm"
|
||||
prompt = "Enable metrics collection"
|
||||
default = false
|
||||
help = "Track performance metrics and statistics"
|
||||
nickel_path = ["enable_metrics"]
|
||||
order = 8
|
||||
|
||||
[[elements]]
|
||||
name = "enable_logging"
|
||||
type = "confirm"
|
||||
prompt = "Enable logging"
|
||||
default = true
|
||||
help = "Log application events and errors"
|
||||
nickel_path = ["enable_logging"]
|
||||
order = 9
|
||||
|
||||
[[elements]]
|
||||
name = "log_level"
|
||||
type = "select"
|
||||
prompt = "Log Level"
|
||||
when = "enable_logging == true"
|
||||
default = "info"
|
||||
options = [
|
||||
{ value = "debug", label = "Debug - All events" },
|
||||
{ value = "info", label = "Info - Important events" },
|
||||
{ value = "warn", label = "Warn - Warnings and errors" },
|
||||
{ value = "error", label = "Error - Errors only" },
|
||||
]
|
||||
nickel_path = ["log_level"]
|
||||
order = 10
|
||||
|
||||
# ========================================================
|
||||
# SUMMARY
|
||||
# ========================================================
|
||||
|
||||
[[elements]]
|
||||
name = "summary_header"
|
||||
type = "section_header"
|
||||
title = "✅ Configuration Summary"
|
||||
border_top = true
|
||||
|
||||
[[elements]]
|
||||
name = "summary"
|
||||
type = "section"
|
||||
content = "Review your configuration above. All settings will be validated against the Nickel schema before saving."
|
||||
119
examples/07-nickel-generation/arrays-schema.ncl
Normal file
119
examples/07-nickel-generation/arrays-schema.ncl
Normal file
@ -0,0 +1,119 @@
|
||||
# Nickel Schema: Array Types with RepeatingGroup Integration
|
||||
#
|
||||
# This example demonstrates how TypeDialog's RepeatingGroup field type
|
||||
# integrates with Nickel's Array(Record) types for managing collections
|
||||
# of structured data.
|
||||
|
||||
{
|
||||
# ========================================================
|
||||
# Record Types (Reusable Structures)
|
||||
# ========================================================
|
||||
|
||||
# UDP Tracker listener configuration
|
||||
TrackerUdp = {
|
||||
# Bind address for UDP listener (e.g., "0.0.0.0:6969")
|
||||
bind_address | String,
|
||||
|
||||
# Number of worker threads (optional)
|
||||
workers | Number | default = 4,
|
||||
},
|
||||
|
||||
# HTTP Tracker listener configuration
|
||||
TrackerHttp = {
|
||||
# Bind address for HTTP listener (e.g., "0.0.0.0:7070")
|
||||
bind_address | String,
|
||||
|
||||
# Protocol: http or https
|
||||
protocol | [| 'http, 'https |] | default = 'http,
|
||||
|
||||
# Number of worker threads (optional)
|
||||
workers | Number | default = 8,
|
||||
},
|
||||
|
||||
# API Endpoint configuration
|
||||
ApiEndpoint = {
|
||||
# Endpoint path (e.g., "/api/v1")
|
||||
path | String,
|
||||
|
||||
# Enable authentication on this endpoint
|
||||
require_auth | Bool | default = true,
|
||||
|
||||
# Rate limit (requests per minute)
|
||||
rate_limit | Number | default = 100,
|
||||
},
|
||||
|
||||
# User/Account configuration
|
||||
User = {
|
||||
# Username (must be alphanumeric with underscores)
|
||||
username | String,
|
||||
|
||||
# Email address
|
||||
email | String,
|
||||
|
||||
# User role
|
||||
role | [| 'admin, 'moderator, 'user |] | default = 'user,
|
||||
|
||||
# Is account active
|
||||
active | Bool | default = true,
|
||||
},
|
||||
|
||||
# ========================================================
|
||||
# Main Configuration (Maps to Form)
|
||||
# ========================================================
|
||||
|
||||
Config = {
|
||||
# ~~~~~ Tracker Configuration ~~~~~
|
||||
|
||||
# Tracker mode: public or private
|
||||
tracker_mode | [| 'public, 'private |],
|
||||
|
||||
# Array of UDP tracker listeners
|
||||
# In TypeDialog: RepeatingGroup field with fragment defining TrackerUdp fields
|
||||
# User can add/edit/delete multiple UDP trackers
|
||||
udp_trackers | Array TrackerUdp | optional,
|
||||
|
||||
# Array of HTTP tracker listeners
|
||||
# In TypeDialog: RepeatingGroup field with fragment defining TrackerHttp fields
|
||||
http_trackers | Array TrackerHttp | optional,
|
||||
|
||||
# ~~~~~ API Configuration ~~~~~
|
||||
|
||||
# Admin API token for authentication
|
||||
api_token | String,
|
||||
|
||||
# API port number (1024-65535)
|
||||
api_port | Number
|
||||
| std.number.is_between 1024 65535 ?
|
||||
| "API port must be between 1024 and 65535",
|
||||
|
||||
# Array of API endpoints exposed by the service
|
||||
# In TypeDialog: RepeatingGroup field with fragment defining ApiEndpoint fields
|
||||
api_endpoints | Array ApiEndpoint | optional,
|
||||
|
||||
# ~~~~~ User Management ~~~~~
|
||||
|
||||
# Array of users/accounts
|
||||
# In TypeDialog: RepeatingGroup field with fragment defining User fields
|
||||
# Admin can add/edit/delete user accounts
|
||||
users | Array User | optional,
|
||||
|
||||
# ~~~~~ Optional Features ~~~~~
|
||||
|
||||
# Enable metrics collection
|
||||
enable_metrics | Bool | default = false,
|
||||
|
||||
# Enable logging (if enabled, requires log_level)
|
||||
enable_logging | Bool | default = true,
|
||||
|
||||
# Log level if logging is enabled
|
||||
log_level | [| 'debug, 'info, 'warn, 'error |]
|
||||
| default = 'info
|
||||
| std.function.optional_if (! enable_logging),
|
||||
},
|
||||
|
||||
# ========================================================
|
||||
# Export the main configuration as default
|
||||
# ========================================================
|
||||
|
||||
Config
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
name = "api_endpoint_item"
|
||||
description = "API Endpoint configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "path"
|
||||
type = "text"
|
||||
prompt = "Endpoint Path"
|
||||
placeholder = "/api/v1/stats"
|
||||
help = "API endpoint path (e.g., /api/v1/stats, /api/v2/submit)"
|
||||
required = true
|
||||
order = 1
|
||||
|
||||
[[elements]]
|
||||
name = "require_auth"
|
||||
type = "confirm"
|
||||
prompt = "Require Authentication"
|
||||
default = true
|
||||
help = "Require admin token for this endpoint"
|
||||
order = 2
|
||||
|
||||
[[elements]]
|
||||
name = "rate_limit"
|
||||
type = "text"
|
||||
prompt = "Rate Limit (req/min)"
|
||||
placeholder = "100"
|
||||
default = "100"
|
||||
help = "Maximum requests per minute (0 = unlimited)"
|
||||
order = 3
|
||||
42
examples/07-nickel-generation/fragments/user-item.toml
Normal file
42
examples/07-nickel-generation/fragments/user-item.toml
Normal file
@ -0,0 +1,42 @@
|
||||
name = "user_item"
|
||||
description = "User account configuration"
|
||||
display_mode = "complete"
|
||||
|
||||
[[elements]]
|
||||
name = "username"
|
||||
type = "text"
|
||||
prompt = "Username"
|
||||
placeholder = "john_doe"
|
||||
help = "Unique username (alphanumeric and underscores)"
|
||||
required = true
|
||||
order = 1
|
||||
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Email Address"
|
||||
placeholder = "user@example.com"
|
||||
help = "User email address"
|
||||
required = true
|
||||
order = 2
|
||||
|
||||
[[elements]]
|
||||
name = "role"
|
||||
type = "select"
|
||||
prompt = "User Role"
|
||||
default = "user"
|
||||
options = [
|
||||
{ value = "admin", label = "Admin (full access)" },
|
||||
{ value = "moderator", label = "Moderator (moderation only)" },
|
||||
{ value = "user", label = "User (basic access)" },
|
||||
]
|
||||
required = true
|
||||
order = 3
|
||||
|
||||
[[elements]]
|
||||
name = "active"
|
||||
type = "confirm"
|
||||
prompt = "Active"
|
||||
default = true
|
||||
help = "Enable or disable user account"
|
||||
order = 4
|
||||
@ -1,56 +1,67 @@
|
||||
name = "employee_onboarding"
|
||||
description = "Employee onboarding with dynamic templates"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "welcome"
|
||||
type = "section"
|
||||
template = "Welcome to the team, {{ env.USER }}!"
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "instructions"
|
||||
type = "section"
|
||||
content = "Please complete the following information to get started."
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "full_name"
|
||||
type = "text"
|
||||
prompt = "What is your full name?"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "email"
|
||||
type = "text"
|
||||
prompt = "Enter your work email address"
|
||||
default = "{{ env.USER }}@company.com"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "department"
|
||||
type = "select"
|
||||
prompt = "Which department are you joining?"
|
||||
options = ["Engineering", "Marketing", "Sales", "Support", "HR"]
|
||||
options = [
|
||||
{ value = "Engineering", label = "💻 Engineering" },
|
||||
{ value = "Marketing", label = "📢 Marketing" },
|
||||
{ value = "Sales", label = "💼 Sales" },
|
||||
{ value = "Support", label = "🛠️ Support" },
|
||||
{ value = "HR", label = "👥 Human Resources" },
|
||||
]
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "start_date"
|
||||
type = "date"
|
||||
prompt = "What is your start date?"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "manager_name"
|
||||
type = "text"
|
||||
prompt = "Who is your manager?"
|
||||
required = true
|
||||
|
||||
[[fields]]
|
||||
[[elements]]
|
||||
name = "office_location"
|
||||
type = "select"
|
||||
prompt = "Which office location?"
|
||||
options = ["New York", "San Francisco", "London", "Remote"]
|
||||
options = [
|
||||
{ value = "New York", label = "🗽 New York" },
|
||||
{ value = "San Francisco", label = "🌉 San Francisco" },
|
||||
{ value = "London", label = "🇬🇧 London" },
|
||||
{ value = "Remote", label = "🏠 Remote" },
|
||||
]
|
||||
required = true
|
||||
|
||||
[[items]]
|
||||
[[elements]]
|
||||
name = "closing"
|
||||
type = "footer"
|
||||
content = "Thank you for completing your onboarding! Welcome aboard!"
|
||||
|
||||
@ -39,10 +39,19 @@ default = "true"
|
||||
name = "role"
|
||||
type = "select"
|
||||
prompt = "Select your role"
|
||||
options = ["Admin", "User", "Guest"]
|
||||
options = [
|
||||
{ value = "Admin", label = "👑 Administrator" },
|
||||
{ value = "User", label = "👤 Regular User" },
|
||||
{ value = "Guest", label = "👁️ Guest" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
name = "interests"
|
||||
type = "multiselect"
|
||||
prompt = "Select your interests"
|
||||
options = ["Technology", "Design", "Business", "Marketing"]
|
||||
options = [
|
||||
{ value = "Technology", label = "💻 Technology" },
|
||||
{ value = "Design", label = "🎨 Design" },
|
||||
{ value = "Business", label = "💼 Business" },
|
||||
{ value = "Marketing", label = "📢 Marketing" },
|
||||
]
|
||||
|
||||
@ -30,7 +30,17 @@ required = true
|
||||
name = "country"
|
||||
type = "select"
|
||||
prompt = "Select your country"
|
||||
options = ["United States", "Canada", "Mexico", "United Kingdom", "Germany", "France", "Spain", "Italy", "Other"]
|
||||
options = [
|
||||
{ value = "United States", label = "🇺🇸 United States" },
|
||||
{ value = "Canada", label = "🇨🇦 Canada" },
|
||||
{ value = "Mexico", label = "🇲🇽 Mexico" },
|
||||
{ value = "United Kingdom", label = "🇬🇧 United Kingdom" },
|
||||
{ value = "Germany", label = "🇩🇪 Germany" },
|
||||
{ value = "France", label = "🇫🇷 France" },
|
||||
{ value = "Spain", label = "🇪🇸 Spain" },
|
||||
{ value = "Italy", label = "🇮🇹 Italy" },
|
||||
{ value = "Other", label = "🌍 Other" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
name = "company"
|
||||
@ -54,7 +64,14 @@ default = true
|
||||
name = "interests"
|
||||
type = "multiselect"
|
||||
prompt = "Select your interests"
|
||||
options = ["Technology", "Design", "Marketing", "Business", "Development", "Data Science"]
|
||||
options = [
|
||||
{ value = "Technology", label = "💻 Technology" },
|
||||
{ value = "Design", label = "🎨 Design" },
|
||||
{ value = "Marketing", label = "📢 Marketing" },
|
||||
{ value = "Business", label = "💼 Business" },
|
||||
{ value = "Development", label = "🔧 Development" },
|
||||
{ value = "Data Science", label = "📊 Data Science" },
|
||||
]
|
||||
|
||||
[[fields]]
|
||||
name = "agreed_terms"
|
||||
|
||||
@ -69,7 +69,27 @@ Reusable components and form composition.
|
||||
- Fragment templates
|
||||
- Includes and inheritance
|
||||
- Component libraries
|
||||
- **Best for:** Large projects, DRY principle, multiple forms
|
||||
- **Array management** with RepeatingGroup fields (add/edit/delete)
|
||||
- **Unique item validation** - prevent duplicate entries in arrays (all backends: CLI, TUI, Web)
|
||||
- **min/max items constraints** - enforce array size limits
|
||||
- **Best for:** Large projects, DRY principle, multiple forms, collections with constraints
|
||||
|
||||
**Key examples:**
|
||||
- [`array-trackers.toml`](05-fragments/array-trackers.toml) - UDP/HTTP tracker arrays with `unique = true`
|
||||
- [`fragments/tracker-udp-item.toml`](05-fragments/fragments/tracker-udp-item.toml) - UDP listener item structure
|
||||
- [`fragments/tracker-http-item.toml`](05-fragments/fragments/tracker-http-item.toml) - HTTP listener item structure
|
||||
|
||||
**Testing RepeatingGroups:**
|
||||
```bash
|
||||
# CLI - Interactive menu with add/edit/delete
|
||||
cargo run --example array_trackers
|
||||
|
||||
# TUI - Split-pane interface with keyboard shortcuts
|
||||
cargo run -p typedialog-tui --example array_trackers
|
||||
|
||||
# Web - Inline expandable cards with live counter
|
||||
cargo run -p typedialog-web -- --config examples/05-fragments/array-trackers.toml
|
||||
```
|
||||
|
||||
### 6. **Integrations** → [`06-integrations/`](06-integrations/)
|
||||
External tool integrations.
|
||||
@ -79,6 +99,18 @@ External tool integrations.
|
||||
| **Nickel** (Type-safe schemas) | [`06-integrations/nickel/`](06-integrations/nickel/) |
|
||||
| **i18n** (Multi-language) | [`06-integrations/i18n/`](06-integrations/i18n/) |
|
||||
|
||||
### 7. **Nickel Schema Generation** → [`07-nickel-generation/`](07-nickel-generation/)
|
||||
Nickel type-safe schemas with TypeDialog form integration.
|
||||
- Array(Record) types for collections
|
||||
- RepeatingGroup field mapping to Nickel arrays
|
||||
- Complex schema structures
|
||||
- **Best for:** Type-safe configuration, validation, schema-driven forms
|
||||
|
||||
**Key examples:**
|
||||
- [`arrays-schema.ncl`](07-nickel-generation/arrays-schema.ncl) - Complete schema with Array types
|
||||
- [`arrays-form.toml`](07-nickel-generation/arrays-form.toml) - Form with RepeatingGroup arrays
|
||||
- Fragments for: `api-endpoint-item.toml`, `user-item.toml`
|
||||
|
||||
### 9. **Real-World Templates** → [`09-templates/`](09-templates/)
|
||||
Production-ready examples for common use cases.
|
||||
|
||||
@ -105,8 +137,15 @@ START HERE
|
||||
└→ 04-backends/web/ ← Web deployment
|
||||
↓
|
||||
05-fragments/ ← Scale to multiple forms
|
||||
↓
|
||||
├→ array-trackers.toml ← Manage collections with RepeatingGroup
|
||||
│
|
||||
06-integrations/ ← Advanced integrations
|
||||
├→ Nickel schemas
|
||||
└→ i18n translations
|
||||
↓
|
||||
07-nickel-generation/ ← Type-safe schemas with arrays
|
||||
├→ arrays-schema.ncl ← Array(Record) types
|
||||
└→ arrays-form.toml ← RepeatingGroup fields
|
||||
↓
|
||||
09-templates/ ← Deploy to production
|
||||
```
|
||||
|
||||
@ -1,656 +0,0 @@
|
||||
//! Integration tests for Nickel ↔ typedialog bidirectional workflows
|
||||
//!
|
||||
//! Tests the complete workflow:
|
||||
//! 1. Nickel schema → metadata extraction
|
||||
//! 2. Metadata → TOML form generation
|
||||
//! 3. Form results → Nickel output serialization
|
||||
//! 4. Template rendering with form results
|
||||
|
||||
use typedialog::nickel::{
|
||||
NickelSchemaIR, NickelFieldIR, NickelType, MetadataParser, TomlGenerator,
|
||||
NickelSerializer, ContractValidator, TemplateEngine,
|
||||
};
|
||||
use typedialog::form_parser;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_simple_schema_roundtrip() {
|
||||
// Create a simple schema
|
||||
let schema = NickelSchemaIR {
|
||||
name: "user_config".to_string(),
|
||||
description: Some("Simple user configuration".to_string()),
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["username".to_string()],
|
||||
flat_name: "username".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User login name".to_string()),
|
||||
default: Some(json!("admin")),
|
||||
optional: false,
|
||||
contract: Some("String | std.string.NonEmpty".to_string()),
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["email".to_string()],
|
||||
flat_name: "email".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User email address".to_string()),
|
||||
default: None,
|
||||
optional: true,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Generate form
|
||||
let form = TomlGenerator::generate(&schema, false, false).expect("Form generation failed");
|
||||
assert_eq!(form.name, "user_config");
|
||||
assert_eq!(form.fields.len(), 2);
|
||||
|
||||
// Verify form fields have nickel metadata
|
||||
assert_eq!(form.fields[0].nickel_contract, Some("String | std.string.NonEmpty".to_string()));
|
||||
assert_eq!(form.fields[0].nickel_path, Some(vec!["username".to_string()]));
|
||||
|
||||
// Create form results
|
||||
let mut results = HashMap::new();
|
||||
results.insert("username".to_string(), json!("alice"));
|
||||
results.insert("email".to_string(), json!("alice@example.com"));
|
||||
|
||||
// Serialize to Nickel
|
||||
let nickel_output = NickelSerializer::serialize(&results, &schema)
|
||||
.expect("Serialization failed");
|
||||
|
||||
// Verify output contains expected content
|
||||
assert!(nickel_output.contains("alice"));
|
||||
assert!(nickel_output.contains("alice@example.com"));
|
||||
assert!(nickel_output.contains("String | std.string.NonEmpty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_schema_with_flatten() {
|
||||
// Create schema with nested structure
|
||||
let schema = NickelSchemaIR {
|
||||
name: "server_config".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["server".to_string(), "hostname".to_string()],
|
||||
flat_name: "server_hostname".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Server hostname".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("server".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["server".to_string(), "port".to_string()],
|
||||
flat_name: "server_port".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: Some("Server port".to_string()),
|
||||
default: Some(json!(8080)),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("server".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["database".to_string(), "host".to_string()],
|
||||
flat_name: "database_host".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Database host".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("database".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Generate form with grouping
|
||||
let form = TomlGenerator::generate(&schema, false, true)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Verify groups are created
|
||||
assert!(form.items.len() > 0);
|
||||
|
||||
// Verify fields have groups
|
||||
let server_hostname = form.fields.iter().find(|f| f.name == "server_hostname").unwrap();
|
||||
assert_eq!(server_hostname.group, Some("server".to_string()));
|
||||
|
||||
// Create results
|
||||
let mut results = HashMap::new();
|
||||
results.insert("server_hostname".to_string(), json!("api.example.com"));
|
||||
results.insert("server_port".to_string(), json!(9000));
|
||||
results.insert("database_host".to_string(), json!("db.example.com"));
|
||||
|
||||
// Serialize and verify nested structure is restored
|
||||
let nickel_output = NickelSerializer::serialize(&results, &schema)
|
||||
.expect("Serialization failed");
|
||||
|
||||
assert!(nickel_output.contains("server"));
|
||||
assert!(nickel_output.contains("database"));
|
||||
assert!(nickel_output.contains("api.example.com"));
|
||||
assert!(nickel_output.contains("db.example.com"));
|
||||
assert!(nickel_output.contains("9000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_serialization() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "array_config".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["tags".to_string()],
|
||||
flat_name: "tags".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: Some("Configuration tags".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut results = HashMap::new();
|
||||
results.insert("tags".to_string(), json!(["prod", "critical", "network"]));
|
||||
|
||||
let nickel_output = NickelSerializer::serialize(&results, &schema)
|
||||
.expect("Serialization failed");
|
||||
|
||||
assert!(nickel_output.contains("prod"));
|
||||
assert!(nickel_output.contains("critical"));
|
||||
assert!(nickel_output.contains("network"));
|
||||
assert!(nickel_output.contains("["));
|
||||
assert!(nickel_output.contains("]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contract_validation_non_empty_string() {
|
||||
// Valid non-empty string
|
||||
let result = ContractValidator::validate(
|
||||
&json!("hello"),
|
||||
"String | std.string.NonEmpty",
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Empty string should fail
|
||||
let result = ContractValidator::validate(
|
||||
&json!(""),
|
||||
"String | std.string.NonEmpty",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contract_validation_number_range() {
|
||||
// Valid number in range
|
||||
let result = ContractValidator::validate(
|
||||
&json!(50),
|
||||
"Number | std.number.between 0 100",
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Number out of range
|
||||
let result = ContractValidator::validate(
|
||||
&json!(150),
|
||||
"Number | std.number.between 0 100",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contract_validation_string_length() {
|
||||
// Valid length
|
||||
let result = ContractValidator::validate(
|
||||
&json!("hello"),
|
||||
"String | std.string.length.min 3",
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Too short
|
||||
let result = ContractValidator::validate(
|
||||
&json!("hi"),
|
||||
"String | std.string.length.min 3",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
// Valid max length
|
||||
let result = ContractValidator::validate(
|
||||
&json!("hi"),
|
||||
"String | std.string.length.max 5",
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Too long
|
||||
let result = ContractValidator::validate(
|
||||
&json!("hello world"),
|
||||
"String | std.string.length.max 5",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_form_definition_from_schema_ir() {
|
||||
// Create schema
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test_form".to_string(),
|
||||
description: Some("Test form".to_string()),
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["name".to_string()],
|
||||
flat_name: "name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Your name".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: Some("String | std.string.NonEmpty".to_string()),
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Generate form
|
||||
let form = TomlGenerator::generate(&schema, false, false)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Convert to TOML
|
||||
let toml_str = toml::to_string_pretty(&form)
|
||||
.expect("TOML serialization failed");
|
||||
|
||||
// Parse back
|
||||
let parsed_form: form_parser::FormDefinition = toml::from_str(&toml_str)
|
||||
.expect("TOML parsing failed");
|
||||
|
||||
// Verify round-trip
|
||||
assert_eq!(parsed_form.name, "test_form");
|
||||
assert_eq!(parsed_form.fields.len(), 1);
|
||||
assert_eq!(parsed_form.fields[0].name, "name");
|
||||
assert_eq!(
|
||||
parsed_form.fields[0].nickel_contract,
|
||||
Some("String | std.string.NonEmpty".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_extraction_with_optional_fields() {
|
||||
// Parse JSON metadata (simulating nickel query output)
|
||||
let metadata = json!({
|
||||
"name": {
|
||||
"doc": "User full name",
|
||||
"type": "String",
|
||||
"default": "Anonymous"
|
||||
},
|
||||
"age": {
|
||||
"doc": "User age in years",
|
||||
"type": "Number",
|
||||
"optional": true
|
||||
},
|
||||
"active": {
|
||||
"type": "Bool",
|
||||
"optional": false,
|
||||
"default": true
|
||||
}
|
||||
});
|
||||
|
||||
let schema = MetadataParser::parse(metadata)
|
||||
.expect("Metadata parsing failed");
|
||||
|
||||
// Verify fields
|
||||
assert_eq!(schema.fields.len(), 3);
|
||||
|
||||
// Check optional flags
|
||||
let name_field = schema.fields.iter().find(|f| f.flat_name == "name").unwrap();
|
||||
assert!(!name_field.optional);
|
||||
|
||||
let age_field = schema.fields.iter().find(|f| f.flat_name == "age").unwrap();
|
||||
assert!(age_field.optional);
|
||||
|
||||
let active_field = schema.fields.iter().find(|f| f.flat_name == "active").unwrap();
|
||||
assert!(!active_field.optional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_mapping_all_types() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "types_test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["str_field".to_string()],
|
||||
flat_name: "str_field".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["num_field".to_string()],
|
||||
flat_name: "num_field".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["bool_field".to_string()],
|
||||
flat_name: "bool_field".to_string(),
|
||||
nickel_type: NickelType::Bool,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["array_field".to_string()],
|
||||
flat_name: "array_field".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, false)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Verify type mapping
|
||||
assert_eq!(form.fields[0].field_type, form_parser::FieldType::Text);
|
||||
assert_eq!(form.fields[1].field_type, form_parser::FieldType::Custom);
|
||||
assert_eq!(form.fields[1].custom_type, Some("f64".to_string()));
|
||||
assert_eq!(form.fields[2].field_type, form_parser::FieldType::Confirm);
|
||||
assert_eq!(form.fields[3].field_type, form_parser::FieldType::Editor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enum_options_extraction_from_doc() {
|
||||
let field = NickelFieldIR {
|
||||
path: vec!["status".to_string()],
|
||||
flat_name: "status".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: Some("Status selection. Options: pending, active, completed".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
};
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![field],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, false)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Verify options extracted
|
||||
let status_field = &form.fields[0];
|
||||
assert_eq!(
|
||||
status_field.options,
|
||||
Some(vec![
|
||||
"pending".to_string(),
|
||||
"active".to_string(),
|
||||
"completed".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_rendering_simple() {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
values.insert("hostname".to_string(), json!("server1"));
|
||||
values.insert("port".to_string(), json!(8080));
|
||||
|
||||
let template = r#"
|
||||
server {
|
||||
hostname : String = "{{ hostname }}"
|
||||
port : Number = {{ port }}
|
||||
}
|
||||
"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("server1"));
|
||||
assert!(output.contains("8080"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_rendering_with_loop() {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
values.insert("servers".to_string(), json!([
|
||||
{"name": "web-01", "ip": "192.168.1.10"},
|
||||
{"name": "web-02", "ip": "192.168.1.11"},
|
||||
]));
|
||||
|
||||
let template = r#"servers = [
|
||||
{% for server in servers %}
|
||||
{ name = "{{ server.name }}", ip = "{{ server.ip }}" },
|
||||
{% endfor %}
|
||||
]"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("web-01"));
|
||||
assert!(output.contains("web-02"));
|
||||
assert!(output.contains("192.168.1.10"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_rendering_with_conditional() {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
values.insert("production".to_string(), json!(true));
|
||||
values.insert("replicas".to_string(), json!(3));
|
||||
|
||||
let template = r#"{% if production %}
|
||||
replicas : Number = {{ replicas }}
|
||||
{% else %}
|
||||
replicas : Number = 1
|
||||
{% endif %}"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("replicas"));
|
||||
assert!(output.contains("3"));
|
||||
assert!(!output.contains("= 1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_workflow_integration() {
|
||||
// Create a realistic schema
|
||||
let schema = NickelSchemaIR {
|
||||
name: "app_config".to_string(),
|
||||
description: Some("Application configuration".to_string()),
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["app".to_string(), "name".to_string()],
|
||||
flat_name: "app_name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Application name".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: Some("String | std.string.NonEmpty".to_string()),
|
||||
group: Some("app".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["app".to_string(), "version".to_string()],
|
||||
flat_name: "app_version".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Application version (e.g., 1.0.0)".to_string()),
|
||||
default: Some(json!("1.0.0")),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("app".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["server".to_string(), "host".to_string()],
|
||||
flat_name: "server_host".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Server hostname".to_string()),
|
||||
default: Some(json!("localhost")),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("server".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["server".to_string(), "port".to_string()],
|
||||
flat_name: "server_port".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: Some("Server port".to_string()),
|
||||
default: Some(json!(8000)),
|
||||
optional: false,
|
||||
contract: Some("Number | std.number.between 1 65535".to_string()),
|
||||
group: Some("server".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Step 1: Generate form
|
||||
let form = TomlGenerator::generate(&schema, false, true)
|
||||
.expect("Form generation failed");
|
||||
assert_eq!(form.fields.len(), 4);
|
||||
|
||||
// Step 2: Serialize to TOML and parse back
|
||||
let toml_str = toml::to_string_pretty(&form)
|
||||
.expect("TOML serialization failed");
|
||||
let parsed_form: form_parser::FormDefinition = toml::from_str(&toml_str)
|
||||
.expect("TOML parsing failed");
|
||||
assert_eq!(parsed_form.fields.len(), 4);
|
||||
|
||||
// Step 3: Create form results
|
||||
let mut results = HashMap::new();
|
||||
results.insert("app_name".to_string(), json!("MyApp"));
|
||||
results.insert("app_version".to_string(), json!("2.0.0"));
|
||||
results.insert("server_host".to_string(), json!("0.0.0.0"));
|
||||
results.insert("server_port".to_string(), json!(3000));
|
||||
|
||||
// Step 4: Validate contracts
|
||||
assert!(ContractValidator::validate(&json!("MyApp"), "String | std.string.NonEmpty").is_ok());
|
||||
assert!(ContractValidator::validate(&json!(3000), "Number | std.number.between 1 65535").is_ok());
|
||||
|
||||
// Step 5: Serialize to Nickel
|
||||
let nickel_output = NickelSerializer::serialize(&results, &schema)
|
||||
.expect("Serialization failed");
|
||||
|
||||
// Step 6: Verify output
|
||||
assert!(nickel_output.contains("MyApp"));
|
||||
assert!(nickel_output.contains("2.0.0"));
|
||||
assert!(nickel_output.contains("0.0.0.0"));
|
||||
assert!(nickel_output.contains("3000"));
|
||||
assert!(nickel_output.contains("String | std.string.NonEmpty"));
|
||||
assert!(nickel_output.contains("Number | std.number.between 1 65535"));
|
||||
|
||||
// Verify nested structure
|
||||
assert!(nickel_output.contains("app"));
|
||||
assert!(nickel_output.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_fields_handling() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "optional_test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["required_field".to_string()],
|
||||
flat_name: "required_field".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["optional_field".to_string()],
|
||||
flat_name: "optional_field".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: true,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, false)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Check required field
|
||||
let required = form.fields.iter().find(|f| f.name == "required_field").unwrap();
|
||||
assert_eq!(required.required, Some(true));
|
||||
|
||||
// Check optional field
|
||||
let optional = form.fields.iter().find(|f| f.name == "optional_field").unwrap();
|
||||
assert_eq!(optional.required, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_defaults_preservation() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "defaults_test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["string_with_default".to_string()],
|
||||
flat_name: "string_with_default".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: Some(json!("default_value")),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["number_with_default".to_string()],
|
||||
flat_name: "number_with_default".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: Some(json!(42)),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, false)
|
||||
.expect("Form generation failed");
|
||||
|
||||
// Check defaults are preserved
|
||||
let string_field = form.fields.iter().find(|f| f.name == "string_with_default").unwrap();
|
||||
assert_eq!(string_field.default, Some("default_value".to_string()));
|
||||
|
||||
let number_field = form.fields.iter().find(|f| f.name == "number_with_default").unwrap();
|
||||
assert_eq!(number_field.default, Some("42".to_string()));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user