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:
Jesús Pérez 2025-12-21 11:38:14 +00:00
parent 82e52fc632
commit 6d045d62c9
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
76 changed files with 10136 additions and 1950 deletions

57
CHANGES.md Normal file
View 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
View File

@ -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",
]

View File

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

View File

@ -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");

View File

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

View File

@ -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))),
}
}

View File

@ -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 = &current_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

View File

@ -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"))
}

View File

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

View File

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

View File

@ -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
}),
);

View File

@ -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();
}
}

View File

@ -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");

View 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())
);
}
}

View File

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

View File

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

View File

@ -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());
}
}

View 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());
}
}

View 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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("}"));
}
}

View File

@ -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()
);
}
}

View File

@ -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");

View File

@ -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()));
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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",
));
}
}

View File

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

View File

@ -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",
));
}
}

View File

@ -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",
));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

@ -1,6 +1,6 @@
name = "header_fragment"
[[items]]
[[elements]]
name = "main_header"
type = "header"
title = "✨ Form with Modular Sections"

View File

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

View File

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

View File

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

View File

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

View 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."

View 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
}

View File

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

View 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

View File

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

View File

@ -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" },
]

View File

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

View File

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

View File

@ -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()));
}