feat(nickel): implement roundtrip and contract parsing

Add contract parsing, i18n extraction, template rendering, and roundtrip
support for Nickel. Update backends and form parser for integration.
Add testing and build infrastructure.
This commit is contained in:
Jesús Pérez 2025-12-19 03:18:48 +00:00
parent 74e26a56a4
commit 82e52fc632
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
45 changed files with 5294 additions and 287 deletions

View File

@ -56,6 +56,7 @@ futures = { workspace = true, optional = true }
[dev-dependencies]
serde_json.workspace = true
tokio = { workspace = true, features = ["full"] }
[features]
default = ["cli", "i18n", "templates"]

View File

@ -6,6 +6,7 @@
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};
@ -34,12 +35,17 @@ pub trait FormBackend: Send + Sync {
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.
///
/// Returns all field values as a map
async fn execute_form_complete(
&mut self,
form: &crate::form_parser::FormDefinition,
items: &[DisplayItem],
fields: &[FieldDefinition],
_base_dir: &Path,
items: Vec<DisplayItem>,
fields: Vec<FieldDefinition>,
) -> Result<std::collections::HashMap<String, Value>> {
// Default implementation: fall back to field-by-field mode
let mut results = std::collections::HashMap::new();
@ -48,11 +54,11 @@ pub trait FormBackend: Send + Sync {
locale: form.locale.clone(),
};
for item in items {
for item in &items {
self.render_display_item(item, &context).await?;
}
for field in fields {
for field in &fields {
context.results = results.clone();
let value = self.execute_field(field, &context).await?;
results.insert(field.name.clone(), value);

View File

@ -23,7 +23,6 @@ use crossterm::{
};
use ratatui::{
backend::CrosstermBackend,
layout::Alignment,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Terminal,
@ -57,6 +56,41 @@ enum ButtonFocus {
Submit,
}
/// Helper to update form elements after a field is saved (reactive re-rendering)
///
/// When a field value changes, conditional sections may appear/disappear.
/// This function recomputes the visible items and fields based on current results.
fn recompute_form_view(
form: &crate::form_parser::FormDefinition,
base_dir: &std::path::Path,
results: &std::collections::HashMap<String, Value>,
items: &mut Vec<DisplayItem>,
fields: &mut Vec<FieldDefinition>,
selected_index: &mut usize,
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)?;
// Update items and fields
*items = new_items;
*fields = new_fields;
// Reset to first visible field
let visible_indices = get_visible_field_indices(fields, results);
if !visible_indices.is_empty() {
*selected_index = visible_indices[0];
field_buffer.clear();
load_field_buffer(field_buffer, &fields[*selected_index], results);
} else {
// No visible fields, shouldn't happen but handle gracefully
*selected_index = 0;
field_buffer.clear();
}
Ok(())
}
/// TUI Backend implementation using ratatui
///
/// Full ratatui implementation with custom widget state machines
@ -113,7 +147,8 @@ impl FormBackend for RatatuiBackend {
}
/// Render a display item with borders, margins, and conditional logic
/// Implements R-RESPONSIVE-LAYOUT with ratatui constraints and custom borders
/// In TUI interactive mode, display items are not rendered since form layout is continuous
/// in the event loop. Display items are CLI-only constructs for output formatting.
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> {
// Check conditional rendering
if let Some(condition) = &item.when {
@ -121,47 +156,7 @@ impl FormBackend for RatatuiBackend {
return Ok(());
}
}
// Render display item with custom border support
{
let mut terminal_ref = self.terminal.write().unwrap();
if let Some(terminal) = terminal_ref.as_mut() {
terminal
.draw(|frame| {
let area = frame.area();
// Render custom borders if specified
if item.border_top.unwrap_or(false) {
render_top_border(frame, area, item);
}
// Render title/content in center
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded);
if let Some(title) = &item.title {
block = block.title(title.as_str());
}
let text = item.content.as_deref().unwrap_or("");
let paragraph = Paragraph::new(text)
.block(block)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
// Render bottom border if specified
if item.border_bottom.unwrap_or(false) {
render_bottom_border(frame, area, item);
}
})
.map_err(|e| Error::validation_failed(format!("Failed to render: {}", e)))?;
} else {
return Err(Error::validation_failed("Terminal not initialized"));
}
}
// Display items not rendered in TUI mode
Ok(())
}
@ -187,13 +182,14 @@ impl FormBackend for RatatuiBackend {
async fn execute_form_complete(
&mut self,
form: &crate::form_parser::FormDefinition,
items: &[DisplayItem],
fields: &[FieldDefinition],
base_dir: &std::path::Path,
mut items: Vec<DisplayItem>,
mut fields: Vec<FieldDefinition>,
) -> Result<std::collections::HashMap<String, Value>> {
let mut results = std::collections::HashMap::new();
// Render display items first
for item in items {
for item in &items {
self.render_display_item(
item,
&super::RenderContext {
@ -210,7 +206,7 @@ impl FormBackend for RatatuiBackend {
let mut field_buffer = String::new();
// Set selected_index to first visible field
let visible_indices = get_visible_field_indices(fields, &results);
let visible_indices = get_visible_field_indices(&fields, &results);
if !visible_indices.is_empty() {
selected_index = visible_indices[0];
}
@ -249,6 +245,10 @@ impl FormBackend for RatatuiBackend {
if let Event::Key(key) = event::read()? {
// Global hotkeys (work in any panel)
match key.code {
KeyCode::Esc => {
// Cancel form with ESC key
return Err(Error::cancelled());
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Exit and submit form with CTRL+E
if focus_panel == FormPanel::InputField {
@ -268,7 +268,7 @@ impl FormBackend for RatatuiBackend {
}
}
// Finalize results with all fields and defaults
finalize_results(&mut results, fields);
finalize_results(&mut results, &fields);
return Ok(results);
}
KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
@ -282,7 +282,7 @@ impl FormBackend for RatatuiBackend {
FormPanel::FieldList => match key.code {
KeyCode::Up => {
// Navigate to previous visible field
let visible_indices = get_visible_field_indices(fields, &results);
let visible_indices = get_visible_field_indices(&fields, &results);
if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) {
if current_pos > 0 {
selected_index = visible_indices[current_pos - 1];
@ -296,7 +296,7 @@ impl FormBackend for RatatuiBackend {
}
KeyCode::Down => {
// Navigate to next visible field
let visible_indices = get_visible_field_indices(fields, &results);
let visible_indices = get_visible_field_indices(&fields, &results);
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];
@ -368,6 +368,10 @@ impl FormBackend for RatatuiBackend {
field.name.clone(),
Value::String(field_buffer.clone()),
);
// 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)?;
focus_panel = FormPanel::FieldList;
}
KeyCode::Esc => {
@ -402,6 +406,10 @@ impl FormBackend for RatatuiBackend {
field.name.clone(),
Value::String(field_buffer.clone()),
);
// 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)?;
focus_panel = FormPanel::FieldList;
}
KeyCode::Esc => {
@ -444,6 +452,10 @@ impl FormBackend for RatatuiBackend {
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)?;
focus_panel = FormPanel::FieldList;
}
KeyCode::Esc => {
@ -477,6 +489,10 @@ impl FormBackend for RatatuiBackend {
field.name.clone(),
Value::String(field_buffer.clone()),
);
// 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)?;
focus_panel = FormPanel::FieldList;
}
KeyCode::Esc => {
@ -509,7 +525,7 @@ impl FormBackend for RatatuiBackend {
}
KeyCode::Enter => match button_focus {
ButtonFocus::Submit => {
finalize_results(&mut results, fields);
finalize_results(&mut results, &fields);
return Ok(results);
}
ButtonFocus::Cancel => return Err(Error::cancelled()),
@ -1462,7 +1478,7 @@ fn render_form_layout(
Style::default()
};
let title_text = format!("Edit: {}", fields[selected_index].prompt);
let title_text = fields[selected_index].prompt.clone();
let block = Block::default()
.title(title_text)
.borders(Borders::ALL)
@ -1763,51 +1779,6 @@ impl DateCursor {
}
}
/// Render top border for display item using custom characters
fn render_top_border(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, item: &DisplayItem) {
let border_left = item.border_top_l.as_deref().unwrap_or("");
let border_char = item.border_top_char.as_deref().unwrap_or("");
let border_len = item.border_top_len.unwrap_or(40);
let border_right = item.border_top_r.as_deref().unwrap_or("");
let margin_left = item.border_margin_left.unwrap_or(0);
let border_line = format!(
"{}{}{}",
border_left,
border_char.repeat(border_len),
border_right
);
let margin_str = " ".repeat(margin_left);
let border_with_margin = format!("{}{}", margin_str, border_line);
let paragraph = Paragraph::new(border_with_margin);
frame.render_widget(paragraph, area);
}
/// Render bottom border for display item using custom characters
fn render_bottom_border(
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
item: &DisplayItem,
) {
let border_left = item.border_bottom_l.as_deref().unwrap_or("");
let border_char = item.border_bottom_char.as_deref().unwrap_or("");
let border_len = item.border_bottom_len.unwrap_or(40);
let border_right = item.border_bottom_r.as_deref().unwrap_or("");
let margin_left = item.border_margin_left.unwrap_or(0);
let border_line = format!(
"{}{}{}",
border_left,
border_char.repeat(border_len),
border_right
);
let margin_str = " ".repeat(margin_left);
let border_with_margin = format!("{}{}", margin_str, border_line);
let paragraph = Paragraph::new(border_with_margin);
frame.render_widget(paragraph, area);
}
/// Helper to evaluate conditional display logic
fn evaluate_condition(
@ -2251,4 +2222,45 @@ mod tests {
// January 1, 2024 is a Monday (0)
assert_eq!(first_day_of_month(2024, 1), 0);
}
// =========================================================================
// TUI REACTIVE RENDERING TESTS (FASE 3)
// =========================================================================
/// Test that recompute_form_view successfully handles form recomputation
/// This validates that the reactive re-rendering infrastructure works correctly
/// by updating visible items and fields based on current results
#[test]
fn test_recompute_form_view_reactive_rendering() {
let mut items: Vec<DisplayItem> = vec![];
let mut fields: Vec<FieldDefinition> = vec![];
let results = std::collections::HashMap::new();
let mut selected_index = 0;
let mut field_buffer = String::new();
// Create minimal form for testing
let form = crate::form_parser::FormDefinition {
name: "test".to_string(),
description: None,
locale: None,
template: None,
output_template: None,
i18n_prefix: None,
display_mode: crate::form_parser::DisplayMode::Complete,
items: vec![],
fields: 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");
// Navigation should be reset to 0
assert_eq!(selected_index, 0);
// Field buffer should be empty when no fields exist
assert!(field_buffer.is_empty());
}
}

View File

@ -7,6 +7,7 @@ use async_trait::async_trait;
use serde_json::{Value, json};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{RwLock, oneshot};
@ -34,6 +35,8 @@ pub struct WebFormState {
is_complete_mode: Arc<RwLock<bool>>,
/// Cached fields for complete mode rendering
complete_fields: Arc<RwLock<Vec<FieldDefinition>>>,
/// Base directory for lazy loading fragments (FASE 4)
base_dir: Arc<RwLock<Option<PathBuf>>>,
}
impl WebFormState {
@ -47,6 +50,7 @@ impl WebFormState {
complete_form_tx: Arc::new(RwLock::new(None)),
is_complete_mode: Arc::new(RwLock::new(false)),
complete_fields: Arc::new(RwLock::new(Vec::new())),
base_dir: Arc::new(RwLock::new(None)),
}
}
}
@ -82,7 +86,7 @@ impl WebBackend {
#[cfg(feature = "web")]
use axum::{
extract::{State, Path},
extract::{State, Path, Query},
http::{StatusCode, HeaderMap},
response::IntoResponse,
routing::{get, post},
@ -108,6 +112,7 @@ impl FormBackend for WebBackend {
let app = Router::new()
.route("/", get(index_handler))
.route("/api/form", get(get_form_handler))
.route("/api/form/dynamic", get(get_dynamic_form_handler))
.route("/api/field/{name}", post(submit_field_handler))
.route("/api/form/complete", post(submit_complete_form_handler))
.with_state(form_state);
@ -267,23 +272,30 @@ impl FormBackend for WebBackend {
async fn execute_form_complete(
&mut self,
form: &crate::form_parser::FormDefinition,
items: &[DisplayItem],
fields: &[FieldDefinition],
base_dir: &std::path::Path,
items: Vec<DisplayItem>,
fields: Vec<FieldDefinition>,
) -> Result<HashMap<String, Value>> {
let state = self.state.as_ref()
.ok_or_else(|| Error::validation_failed("Server not initialized"))?;
// Set complete mode flag and cache fields for get_form_handler
// Set complete mode flag, cache fields, store base_dir, and store form for lazy loading
{
let mut is_complete = state.is_complete_mode.write().await;
*is_complete = true;
let mut cached_fields = state.complete_fields.write().await;
*cached_fields = fields.to_vec();
*cached_fields = fields.clone();
let mut stored_base_dir = state.base_dir.write().await;
*stored_base_dir = Some(base_dir.to_path_buf());
let mut stored_form = state.form.write().await;
*stored_form = Some(form.clone());
}
// Render all display items
for item in items {
for item in &items {
self.render_display_item(item, &RenderContext {
results: HashMap::new(),
locale: form.locale.clone(),
@ -310,7 +322,7 @@ impl FormBackend for WebBackend {
match tokio::time::timeout(Duration::from_secs(300), rx).await {
Ok(Ok(mut all_results)) => {
// Validate required fields
for field in fields {
for field in &fields {
if field.required.unwrap_or(false) {
if let Some(value) = all_results.get(&field.name) {
if value.is_null() || (value.is_string() && value.as_str().unwrap_or("").is_empty()) {
@ -406,11 +418,38 @@ async fn index_handler(State(_state): State<Arc<WebFormState>>) -> impl IntoResp
</script>
</head>
<body>
<div class="form-container">
<div class="form-container" id="main">
<h1>Form Inquire</h1>
<div id="form" hx-get="/api/form" hx-trigger="load, formUpdate from:body" hx-swap="innerHTML"></div>
</div>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// Load initial form
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/form')
.then(r => r.text())
.then(html => {
document.getElementById('main').innerHTML += html;
});
});
// Reactive form field updates
document.addEventListener('change', function(e) {
if (e.target.matches('select, input[type=checkbox], input[type=radio]')) {
const form = document.querySelector('form#complete-form');
if (!form) return;
const formData = new FormData(form);
const params = new URLSearchParams(formData);
fetch('/api/form/dynamic?' + params)
.then(r => r.text())
.then(html => {
document.getElementById('form-fields').innerHTML = html;
})
.catch(err => console.error('Failed to update form:', err));
}
});
</script>
</body>
</html>"#
)
@ -446,6 +485,59 @@ async fn get_form_handler(State(state): State<Arc<WebFormState>>) -> impl IntoRe
))
}
/// Dynamic form endpoint for vanilla JS polling
/// Returns updated field HTML based on current results (reactive rendering)
/// Accepts query parameters containing current form field values
#[cfg(feature = "web")]
async fn get_dynamic_form_handler(
State(state): State<Arc<WebFormState>>,
Query(params): Query<HashMap<String, String>>,
) -> (StatusCode, Html<String>) {
let is_complete = *state.is_complete_mode.read().await;
if !is_complete {
// Only support dynamic rendering in complete form mode
return (StatusCode::BAD_REQUEST, Html("<div>Dynamic form only available in complete mode</div>".to_string()));
}
// Convert query parameters to results HashMap for condition evaluation
// JavaScript sends current field values as query parameters
let mut results: HashMap<String, Value> = HashMap::new();
for (key, value) in params {
results.insert(key, Value::String(value));
}
// Get base_dir and form for recomputation
let base_dir_opt = state.base_dir.read().await.clone();
let form_opt = state.form.read().await.clone();
match (base_dir_opt, form_opt) {
(Some(base_dir), Some(form)) => {
// Recompute visible elements based on current results
match crate::form_parser::recompute_visible_elements(&form, &base_dir, &results) {
Ok((_visible_items, visible_fields)) => {
// Return ONLY field HTML (no wrapper div)
// Wrapper div#form-fields already exists in DOM, JavaScript replaces innerHTML
let fields_html = render_fields_only(&visible_fields, &results);
(StatusCode::OK, Html(fields_html))
}
Err(_) => {
// Fallback: return all fields
let fields = state.complete_fields.read().await.clone();
let fields_html = render_fields_only(&fields, &results);
(StatusCode::OK, Html(fields_html))
}
}
}
_ => {
// Fallback if state not properly initialized
let fields = state.complete_fields.read().await.clone();
let fields_html = render_fields_only(&fields, &HashMap::new());
(StatusCode::OK, Html(fields_html))
}
}
}
#[cfg(feature = "web")]
async fn submit_field_handler(
State(state): State<Arc<WebFormState>>,
@ -593,14 +685,12 @@ use axum::response::Html;
/// Render all fields in a single form for complete mode
fn render_complete_form(fields: &[FieldDefinition]) -> String {
let mut fields_html = String::new();
for field in fields {
fields_html.push_str(&render_field_for_complete_form(field));
}
let fields_html = render_form_fields(fields);
// FASE 4: HTMX polling for reactive rendering
// Form wrapper stays static, only fields div is updated dynamically
format!(
r##"<form id="complete-form" hx-post="/api/form/complete" hx-swap="innerHTML" hx-target="#form">
r##"<form id="complete-form" hx-post="/api/form/complete">
<div style="border: 1px solid #3e3e42; padding: 20px; background: #252526; border-radius: 4px;">
{}
<button type="submit" style="background: #007acc; color: white; border: none; padding: 10px 20px; cursor: pointer; margin-top: 20px; width: 100%; font-weight: bold;">
@ -612,8 +702,33 @@ fn render_complete_form(fields: &[FieldDefinition]) -> String {
)
}
/// Render just the fields wrapper for reactive updates (JavaScript handles reactivity)
fn render_form_fields(fields: &[FieldDefinition]) -> String {
// Initial render has no state yet, use empty HashMap
let fields_html = render_fields_only(fields, &HashMap::new());
// Wrapper div for vanilla JS reactivity
// JavaScript addEventListener listens for change events and updates this div's innerHTML
format!(
r##"<div id="form-fields">
{}
</div>"##,
fields_html
)
}
/// Render field HTML only (no wrapper, for dynamic updates via HTMX)
fn render_fields_only(fields: &[FieldDefinition], results: &HashMap<String, Value>) -> String {
let mut fields_html = String::new();
for field in fields {
fields_html.push_str(&render_field_for_complete_form(field, results));
}
fields_html
}
/// Render a single field for inclusion in the complete form (without individual submit button)
fn render_field_for_complete_form(field: &FieldDefinition) -> String {
/// Includes state injection for selected/checked attributes based on current results
fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap<String, Value>) -> String {
match field.field_type {
FieldType::Text => {
let placeholder = field.placeholder.as_deref().unwrap_or("");
@ -631,21 +746,35 @@ fn render_field_for_complete_form(field: &FieldDefinition) -> String {
)
}
FieldType::Confirm => {
// Get current value from results for state preservation
// Handle both boolean values and string "true"/"false"
let current_value = results.get(&field.name)
.and_then(|v| {
if let Some(b) = v.as_bool() {
Some(b)
} else {
v.as_str().map(|s| s == "true")
}
})
.unwrap_or(false);
format!(
r#"<div class="field" style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">{}</label>
<div style="display: flex; gap: 10px;">
<label style="display: flex; align-items: center; gap: 5px;">
<input type="radio" name="{}" value="true"> Yes
<input type="radio" name="{}" value="true" {}> Yes
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="radio" name="{}" value="false"> No
<input type="radio" name="{}" value="false" {}> No
</label>
</div>
</div>"#,
html_escape(&field.prompt),
html_escape(&field.name),
html_escape(&field.name)
if current_value { "checked" } else { "" },
html_escape(&field.name),
if !current_value { "checked" } else { "" }
)
}
FieldType::Password => {
@ -662,9 +791,18 @@ fn render_field_for_complete_form(field: &FieldDefinition) -> String {
)
}
FieldType::Select => {
// Get current value from results for state preservation
let current_value = results.get(&field.name)
.and_then(|v| v.as_str())
.unwrap_or("");
let options = field.options.as_ref().map(|opts| {
opts.iter()
.map(|opt| format!("<option value=\"{}\">{}</option>", html_escape(opt), html_escape(opt)))
.map(|opt| {
let selected = if opt == current_value { "selected" } else { "" };
format!("<option value=\"{}\" {}>{}</option>",
html_escape(opt), selected, html_escape(opt))
})
.collect::<Vec<_>>()
.join("\n")
}).unwrap_or_default();
@ -685,16 +823,27 @@ fn render_field_for_complete_form(field: &FieldDefinition) -> String {
}
FieldType::MultiSelect => {
let field_name = &field.name;
// Get current selected values from results for state preservation
let selected_values: Vec<String> = results.get(field_name)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let options = field.options.as_ref().map(|opts| {
opts.iter()
.map(|opt| format!(
r#"<label style="display: block; margin: 5px 0; cursor: pointer;">
<input type="checkbox" data-checkbox-group="{}" value="{}"> {}
.map(|opt| {
let checked = if selected_values.contains(&opt.to_string()) { "checked" } else { "" };
format!(
r#"<label style="display: block; margin: 5px 0; cursor: pointer;">
<input type="checkbox" data-checkbox-group="{}" value="{}" {}> {}
</label>"#,
html_escape(field_name),
html_escape(opt),
html_escape(opt)
))
html_escape(field_name),
html_escape(opt),
checked,
html_escape(opt)
)
})
.collect::<Vec<_>>()
.join("\n")
}).unwrap_or_default();
@ -1225,4 +1374,148 @@ mod tests {
assert!(backend.initialize().await.is_ok());
assert!(backend.shutdown().await.is_ok());
}
/// Test dynamic form endpoint returns correct filtered fields
#[tokio::test]
async fn test_web_dynamic_form_endpoint_filters_fields() {
// Create a form with a conditional field using TOML parsing
let toml = r#"
name = "test"
display_mode = "complete"
[[fields]]
name = "account_type"
type = "select"
prompt = "Account Type"
options = ["basic", "premium"]
default = "basic"
[[fields]]
name = "premium_feature"
type = "text"
prompt = "Premium Feature"
when = "account_type == premium"
"#;
let form = crate::form_parser::parse_toml(toml).expect("Failed to parse form");
// Create state
let state = WebFormState::new();
// Set form, base_dir, complete mode, and initial fields
{
let mut form_lock = state.form.write().await;
*form_lock = Some(form.clone());
let mut base_dir_lock = state.base_dir.write().await;
*base_dir_lock = Some(std::path::PathBuf::from("/tmp"));
let mut is_complete = state.is_complete_mode.write().await;
*is_complete = true;
let mut fields_lock = state.complete_fields.write().await;
*fields_lock = form.fields.clone();
}
// Call the handler with current field values as query parameters
// Simulate HTMX sending: account_type=premium
let mut params = HashMap::new();
params.insert("account_type".to_string(), "premium".to_string());
let (status, Html(html_str)) = get_dynamic_form_handler(
State(Arc::new(state)),
Query(params),
).await;
// Verify the response is OK
assert_eq!(status, StatusCode::OK, "Should return 200 OK");
// Verify the response contains the account_type field
assert!(html_str.contains("account_type"), "Should contain account_type field");
// With account_type == premium, premium_feature should be visible
assert!(html_str.contains("premium_feature"), "Should contain premium_feature when condition is true");
}
/// Test dynamic form endpoint with basic account type hides premium fields
#[tokio::test]
async fn test_web_dynamic_form_endpoint_hides_conditional() {
// Create a form with a conditional field using TOML parsing
let toml = r#"
name = "test"
display_mode = "complete"
[[fields]]
name = "account_type"
type = "select"
prompt = "Account Type"
options = ["basic", "premium"]
default = "basic"
[[fields]]
name = "premium_feature"
type = "text"
prompt = "Premium Feature"
when = "account_type == premium"
"#;
let form = crate::form_parser::parse_toml(toml).expect("Failed to parse form");
// Create state
let state = WebFormState::new();
// Set form, base_dir, complete mode, and initial fields
{
let mut form_lock = state.form.write().await;
*form_lock = Some(form.clone());
let mut base_dir_lock = state.base_dir.write().await;
*base_dir_lock = Some(std::path::PathBuf::from("/tmp"));
let mut is_complete = state.is_complete_mode.write().await;
*is_complete = true;
let mut fields_lock = state.complete_fields.write().await;
*fields_lock = form.fields.clone();
}
// Call the handler with current field values as query parameters
// Simulate HTMX sending: account_type=basic
let mut params = HashMap::new();
params.insert("account_type".to_string(), "basic".to_string());
let (status, Html(html_str)) = get_dynamic_form_handler(
State(Arc::new(state)),
Query(params),
).await;
// Verify the response is OK
assert_eq!(status, StatusCode::OK, "Should return 200 OK");
// Should still have account_type field
assert!(html_str.contains("account_type"), "Should contain account_type field");
// But NOT premium_feature since account_type == basic
assert!(!html_str.contains("premium_feature"), "Should NOT contain premium_feature when condition is false");
}
/// Test dynamic form endpoint handles non-complete mode gracefully
#[tokio::test]
async fn test_web_dynamic_form_endpoint_non_complete_mode() {
let state = WebFormState::new();
// NOT in complete mode
{
let mut is_complete = state.is_complete_mode.write().await;
*is_complete = false;
}
// Call the handler with empty params (non-complete mode should fail regardless)
let params = HashMap::new();
let (status, _html) = get_dynamic_form_handler(
State(Arc::new(state)),
Query(params),
).await;
// Should return BAD_REQUEST for non-complete mode
assert_eq!(status, StatusCode::BAD_REQUEST);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -191,9 +191,19 @@ impl NickelCli {
///
/// Returns an error if the nickel typecheck command fails.
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(
ErrorKind::Other,
"Cannot extract filename from path".to_string(),
))?;
let output = Command::new("nickel")
.current_dir(parent_dir)
.arg("typecheck")
.arg(path)
.arg(filename)
.output()
.map_err(|e| {
Error::new(

View File

@ -0,0 +1,274 @@
//! Contract Parser for Nickel sources
//!
//! 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 std::collections::HashMap;
/// Parser for extracting contract metadata from Nickel source
pub struct ContractParser;
/// Result of parsing a Nickel file for contracts
#[derive(Debug, Clone)]
pub struct ParsedContracts {
/// Imports found: map of imported name to module path
/// Example: validators -> ../validators/environment.ncl
pub imports: HashMap<String, String>,
/// Contract calls found per field
/// Example: "environment_name" -> ContractCall { module: "validators", function: "ValidEnvironmentName", ... }
pub field_contracts: HashMap<String, ContractCall>,
/// Raw source for template rendering
pub source: String,
}
impl ContractParser {
/// Parse a Nickel source file and extract contract information
pub fn parse_source(source: &str) -> Result<ParsedContracts> {
Ok(ParsedContracts {
imports: Self::extract_imports(source),
field_contracts: Self::extract_contract_calls(source),
source: source.to_string(),
})
}
/// Extract import statements from Nickel source
/// Pattern: let NAME = import "PATH" in
fn extract_imports(source: &str) -> HashMap<String, String> {
let mut imports = HashMap::new();
for line in source.lines() {
let trimmed = line.trim();
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('"') {
if let Some(quote_end) = trimmed[quote_start + 1..].find('"') {
let path_part = &trimmed[quote_start + 1..quote_start + 1 + quote_end];
imports.insert(name_part.to_string(), path_part.to_string());
}
}
}
}
}
imports
}
/// Extract contract calls from user_config with field name mapping
/// Pattern: field_name = module.function args
/// Examples: environment_name = validators.ValidPort 22
fn extract_contract_calls(source: &str) -> HashMap<String, ContractCall> {
let mut calls = HashMap::new();
// Find section between "let user_config = {" and "} in"
if let Some(config_start) = source.find("let user_config = {") {
if let Some(config_end) = source[config_start..].find("} in") {
let config_section = &source[config_start..config_start + config_end + 1];
// Find all lines with = and extract field_name = module.function calls
for line in config_section.lines() {
if line.contains('=') && !line.trim().starts_with('#') {
// Parse: field_name = validator.Function args
if let Some(eq_pos) = line.find('=') {
let left_side = line[..eq_pos].trim();
let right_side = line[eq_pos + 1..].trim();
// Extract field name
let field_name = if left_side.is_empty() {
continue;
} else {
left_side.to_string()
};
// 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')) {
// Could be string, array, object, number, true, false, null
// Skip parsing as validator call
eprintln!("[DEBUG] Skipping literal for field '{}': '{}'", field_name, right_side);
continue;
}
eprintln!("[DEBUG] Parsing field '{}': '{}'", field_name, right_side);
// Extract module.function pairs from right side
if let Some(dot_pos) = right_side.find('.') {
// Find the start of the identifier before the dot
let mut module_start = dot_pos;
while module_start > 0
&& Self::is_identifier_char(
right_side.chars().nth(module_start - 1).unwrap(),
)
{
module_start -= 1;
}
let module = right_side[module_start..dot_pos].trim();
// Find the function name after the dot
let after_dot = &right_side[dot_pos + 1..];
let mut function_end = 0;
for ch in after_dot.chars() {
if ch.is_alphanumeric() || ch == '_' {
function_end += 1;
} else {
break;
}
}
if function_end > 0 {
let function = &after_dot[..function_end];
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 = rest[..args_end].trim();
let args = if args.is_empty() {
None
} else {
Some(args.to_string())
};
let expr = format!(
"{}.{}{}",
module,
function,
args.as_ref().map(|a| format!(" {}", a)).unwrap_or_default()
);
let call = ContractCall {
module: module.to_string(),
function: function.to_string(),
args,
expr,
};
// Key by field name, not module.function
calls.insert(field_name, call);
}
}
}
}
}
}
}
calls
}
fn is_identifier_char(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
/// Extract the user_config record from source (between `let user_config = { ... } in`)
pub fn extract_user_config(source: &str) -> Option<String> {
if let Some(start) = source.find("let user_config = {") {
if let Some(end) = source[start..].find("} in") {
let content = &source[start + 19..start + end + 1];
return Some(content.to_string());
}
}
None
}
/// Get the final expression (usually just `user_config`)
pub fn extract_final_expr(source: &str) -> Option<String> {
// Match the last non-comment, non-blank line before EOF
let lines: Vec<&str> = source
.lines()
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
.collect();
lines.last().map(|&line| line.trim().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_contracts_from_file() {
// Test with the actual content from /tmp/minimal_input.ncl
let source = r#"let validators = import "/Users/Akasha/Development/typedialog/provisioning/validators/environment.ncl" in
let user_config = {
env_name = validators.ValidEnvironmentName "dev",
db_name = "tracker.db",
} in
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!(calls.contains_key("env_name"));
assert!(!calls.contains_key("db_name"));
}
#[test]
fn test_extract_contracts_ignores_literals() {
let source = r#"
let validators = import "../validators/environment.ncl" in
let user_config = {
env_name = validators.ValidEnvironmentName "dev",
db_name = "tracker.db",
enabled = true,
count = 42,
} in
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!(calls.contains_key("env_name"));
assert!(!calls.contains_key("db_name"));
assert!(!calls.contains_key("enabled"));
assert!(!calls.contains_key("count"));
}
#[test]
fn test_extract_imports() {
let source = r#"
let validators = import "../validators/environment.ncl" in
let validators_common = import "../validators/common.ncl" in
let user_config = {} in
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"));
}
#[test]
fn test_extract_user_config() {
let source = r#"
let user_config = {
name = "test",
port = 8080,
} in
user_config
"#;
let config = ContractParser::extract_user_config(source);
assert!(config.is_some());
assert!(config.unwrap().contains("name"));
}
#[test]
fn test_extract_final_expr() {
let source = "let x = 1 in\nuser_config";
let expr = ContractParser::extract_final_expr(source);
assert_eq!(expr, Some("user_config".to_string()));
}
}

View File

@ -1,8 +1,9 @@
//! Contract Validator
//! Contract Validator and Analyzer
//!
//! Validates Nickel contracts and predicates against JSON values.
//! Analyzes contracts to infer conditional expressions for form generation.
//!
//! Supports common Nickel validation predicates:
//! Supported validation predicates:
//! - `std.string.NonEmpty` - Non-empty string
//! - `std.string.length.min N` - Minimum string length
//! - `std.string.length.max N` - Maximum string length
@ -13,6 +14,7 @@
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;
@ -247,6 +249,62 @@ impl ContractValidator {
}
}
/// Analyzer for inferring conditional expressions from Nickel contracts
pub struct ContractAnalyzer;
impl ContractAnalyzer {
/// Infer a conditional expression from a field based on optional status and schema context
///
/// 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> {
// Only optional fields might have conditionals
if !field.optional {
return None;
}
// Try to find a boolean field that enables this optional field
Self::find_boolean_dependency(field, schema)
}
/// Find a boolean field that enables this optional field based on naming convention
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)?;
// Look for a boolean field like "tls_enabled"
for candidate in &schema.fields {
if candidate.nickel_type == NickelType::Bool {
let candidate_prefix = Self::extract_prefix(&candidate.flat_name)?;
// Match pattern: same prefix + "_enabled" suffix
if candidate_prefix == field_prefix && candidate.flat_name.ends_with("_enabled") {
return Some(format!("{} == true", candidate.flat_name));
}
}
}
None
}
/// Extract the common prefix from a field name
/// "tls_cert_path" -> "tls"
/// "database_url" -> "database"
fn extract_prefix(flat_name: &str) -> Option<String> {
flat_name
.split('_')
.next()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -0,0 +1,164 @@
//! i18n Extractor
//!
//! Extracts multi-language documentation from Nickel schemas and generates
//! Fluent translation (.ftl) files for form localization.
//!
//! Supports extracting translations from doc comments in format:
//! - `doc "English text"` (default locale: en)
//! - Additional locales parsed from schema metadata
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;
impl I18nExtractor {
/// Extract multi-language documentation and generate .ftl files
///
/// Parses doc comments from all fields in the schema and generates
/// Fluent translation files for each detected locale.
///
/// # Arguments
///
/// * `schema` - The Nickel schema with documented fields
/// * `output_dir` - Directory to write .ftl files
///
/// # Returns
///
/// HashMap mapping field_name -> i18n_key (e.g., "app_name" -> "app-name-prompt")
pub fn extract_and_generate(
schema: &NickelSchemaIR,
output_dir: &Path,
) -> Result<HashMap<String, String>> {
let mut translations: HashMap<String, HashMap<String, String>> = HashMap::new();
// Extract translations from all fields
for field in &schema.fields {
if let Some(doc) = &field.doc {
// Parse multi-language doc comments
let lang_docs = Self::parse_multi_lang_doc(doc);
// Add to translations by locale
for (locale, text) in lang_docs {
translations
.entry(locale)
.or_default()
.insert(field.flat_name.clone(), text);
}
}
}
// 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)))?;
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)))?;
}
// Return mapping of field_name -> i18n_key
let mut key_mapping = HashMap::new();
for field in &schema.fields {
let i18n_key = Self::field_name_to_i18n_key(&field.flat_name);
key_mapping.insert(field.flat_name.clone(), i18n_key);
}
Ok(key_mapping)
}
/// Parse multi-language documentation from a field doc comment
///
/// Handles format: "English text" (default to "en" locale)
/// Future: Support "doc.es: Spanish text", "doc.fr: French text" format
fn parse_multi_lang_doc(doc: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
// For now, treat all doc as English (en)
// In the future, parse format like: "en: English | es: Spanish | fr: French"
if !doc.is_empty() {
result.insert("en".to_string(), doc.to_string());
}
result
}
/// Generate Fluent (.ftl) content from messages
fn generate_ftl_content(messages: &HashMap<String, String>) -> String {
let mut content = String::new();
content.push_str("# Form translations\n\n");
// Sort for consistent output
let mut sorted_keys: Vec<_> = messages.keys().collect();
sorted_keys.sort();
for key in sorted_keys {
if let Some(text) = messages.get(key.as_str()) {
let ftl_key = Self::field_name_to_i18n_key(key);
// Escape quotes in text
let escaped_text = text.replace('"', "\\\"");
content.push_str(&format!("{}-prompt = {}\n", ftl_key, escaped_text));
content.push_str(&format!("{}-help = {}\n\n", ftl_key, escaped_text));
}
}
content
}
/// Convert field name to i18n key format
///
/// "app_name" -> "app-name"
/// "tls_cert_path" -> "tls-cert-path"
fn field_name_to_i18n_key(field_name: &str) -> String {
field_name.replace('_', "-")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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("tls_cert_path"),
"tls-cert-path"
);
assert_eq!(
I18nExtractor::field_name_to_i18n_key("database_url"),
"database-url"
);
}
#[test]
fn test_parse_multi_lang_doc() {
let doc = "Application name";
let result = I18nExtractor::parse_multi_lang_doc(doc);
assert_eq!(result.get("en"), Some(&"Application name".to_string()));
}
#[test]
fn test_generate_ftl_content() {
let mut messages = HashMap::new();
messages.insert("app_name".to_string(), "Application name".to_string());
messages.insert("app_version".to_string(), "Application version".to_string());
let content = I18nExtractor::generate_ftl_content(&messages);
assert!(content.contains("app-name-prompt"));
assert!(content.contains("Application name"));
assert!(content.contains("app-version-prompt"));
assert!(content.contains("Application version"));
}
}

View File

@ -35,12 +35,20 @@ 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 use cli::NickelCli;
pub use schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
pub use schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType, ContractCall};
pub use parser::MetadataParser;
pub use toml_generator::TomlGenerator;
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};

View File

@ -8,11 +8,14 @@
//! - Default values (| default)
//! - Optional fields (| optional)
//! - Nested record structures
//! - Fragment markers from `# @fragment: name` comments
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;
/// Parser for Nickel metadata JSON from `nickel query` output
pub struct MetadataParser;
@ -95,7 +98,9 @@ impl MetadataParser {
default,
optional,
contract,
contract_call: None, // Will be assigned from contract_parser if present
group: None, // Will be assigned during form generation
fragment_marker: None, // Will be assigned from source comments if present
})
}
@ -192,6 +197,85 @@ impl MetadataParser {
}
}
}
/// Extract fragment markers from Nickel source file
///
/// 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>> {
let source_code = std::fs::read_to_string(source_path)
.map_err(|e| Error::new(ErrorKind::Io, format!("Failed to read source file: {}", e)))?;
let mut markers = HashMap::new();
let mut current_fragment: Option<String> = None;
for line in source_code.lines() {
let trimmed = line.trim();
// Check for fragment marker comment
if let Some(fragment_name) = Self::extract_fragment_marker_from_comment(trimmed) {
current_fragment = Some(fragment_name);
}
// Skip comments and empty lines
else if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Parse field name from line (simple heuristic: identifier before | or :)
else if let Some(field_name) = Self::extract_field_name(trimmed) {
if let Some(fragment) = &current_fragment {
markers.insert(field_name, fragment.clone());
}
}
}
Ok(markers)
}
/// Extract fragment marker from a comment line
fn extract_fragment_marker_from_comment(line: &str) -> Option<String> {
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();
if !fragment_name.is_empty() {
return Some(fragment_name);
}
}
None
}
/// Extract field name from a line (e.g., "field_name | doc" -> "field_name")
fn extract_field_name(line: &str) -> Option<String> {
// Simple heuristic: take the first identifier
let name = line
.split_whitespace()
.next()?
.split('|')
.next()?
.split(':')
.next()?;
if !name.is_empty() && !name.ends_with(',') {
return Some(name.to_string());
}
None
}
/// Apply fragment markers to parsed schema fields
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());
}
}
}
}
#[cfg(test)]

View File

@ -0,0 +1,227 @@
//! Nickel Roundtrip Workflow
//!
//! Orchestrates complete workflow:
//! 1. Read .ncl source file
//! 2. Parse contracts and imports
//! 3. Load form definition
//! 4. Execute form (user input)
//! 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 serde_json::Value;
/// Configuration for a roundtrip workflow
pub struct RoundtripConfig {
/// Path to input .ncl file
pub input_ncl: PathBuf,
/// Path to form definition (.toml)
pub form_path: PathBuf,
/// Path to output .ncl file
pub output_ncl: PathBuf,
/// Optional template file (.ncl.j2) for rendering output
/// If provided, renders template instead of using contract parser
pub template_ncl: Option<PathBuf>,
/// Whether to validate output with nickel typecheck
pub validate: bool,
/// Whether to print intermediate steps
pub verbose: bool,
}
/// Result of a roundtrip execution
pub struct RoundtripResult {
/// Parsed contracts from input
pub input_contracts: super::contract_parser::ParsedContracts,
/// Form results (field values)
pub form_results: std::collections::HashMap<String, Value>,
/// Generated Nickel code
pub output_nickel: String,
/// Validation result (if enabled)
pub validation_passed: Option<bool>,
}
impl RoundtripConfig {
/// Create a new roundtrip configuration
pub fn new(input_ncl: PathBuf, form_path: PathBuf, output_ncl: PathBuf) -> Self {
Self {
input_ncl,
form_path,
output_ncl,
template_ncl: None,
validate: true,
verbose: false,
}
}
/// Create a roundtrip configuration with an optional template file
pub fn with_template(
input_ncl: PathBuf,
form_path: PathBuf,
output_ncl: PathBuf,
template_ncl: Option<PathBuf>,
) -> Self {
Self {
input_ncl,
form_path,
output_ncl,
template_ncl,
validate: true,
verbose: false,
}
}
/// Execute the complete roundtrip workflow
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)?
} 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 and return results
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)?;
// Execute form using CLI backend (interactive prompts)
form_parser::execute(form)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roundtrip_config_creation() {
let config = RoundtripConfig::new(
PathBuf::from("input.ncl"),
PathBuf::from("form.toml"),
PathBuf::from("output.ncl"),
);
assert_eq!(config.input_ncl, PathBuf::from("input.ncl"));
assert_eq!(config.form_path, PathBuf::from("form.toml"));
assert_eq!(config.output_ncl, PathBuf::from("output.ncl"));
assert!(config.validate);
}
}

View File

@ -21,8 +21,24 @@ pub struct NickelSchemaIR {
pub fields: Vec<NickelFieldIR>,
}
/// Represents a contract call: module.function(args)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContractCall {
/// Module name (e.g., "validators", "validators_common", "std.string")
pub module: String,
/// Function/validator name (e.g., "ValidPort", "NonEmpty")
pub function: String,
/// Arguments passed to the validator (e.g., "22", "\"dev\"")
pub args: Option<String>,
/// Full expression string (e.g., "validators.ValidPort 22")
pub expr: String,
}
/// Intermediate representation of a single field in a Nickel schema
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NickelFieldIR {
/// Path to field: ["user", "name"] for user.name
pub path: Vec<String>,
@ -45,12 +61,18 @@ pub struct NickelFieldIR {
/// Nickel contract/predicate (e.g., "std.string.NonEmpty")
pub contract: Option<String>,
/// Parsed contract call information (module.function args)
pub contract_call: Option<ContractCall>,
/// Semantic grouping for form UI
pub group: Option<String>,
/// Fragment marker from # @fragment: comment
pub fragment_marker: Option<String>,
}
/// Nickel type information for a field
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum NickelType {
/// String type
String,
@ -105,4 +127,32 @@ impl NickelSchemaIR {
groups.dedup();
groups
}
/// Get all fields in a specific fragment
pub fn fields_by_fragment(&self, fragment: &str) -> Vec<&NickelFieldIR> {
self.fields
.iter()
.filter(|f| f.fragment_marker.as_deref() == Some(fragment))
.collect()
}
/// Get all unique fragments in the schema
pub fn fragments(&self) -> Vec<String> {
let mut fragments: Vec<_> = self
.fields
.iter()
.filter_map(|f| f.fragment_marker.clone())
.collect();
fragments.sort();
fragments.dedup();
fragments
}
/// Get fields without a fragment marker (ungrouped)
pub fn fields_without_fragment(&self) -> Vec<&NickelFieldIR> {
self.fields
.iter()
.filter(|f| f.fragment_marker.is_none())
.collect()
}
}

View File

@ -215,7 +215,9 @@ mod tests {
default: None,
optional: false,
contract: Some("String | std.string.NonEmpty".to_string()),
contract_call: None,
group: None,
fragment_marker: None,
},
NickelFieldIR {
path: vec!["age".to_string()],
@ -225,7 +227,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
NickelFieldIR {
path: vec!["active".to_string()],
@ -235,7 +239,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
],
};
@ -271,7 +277,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
NickelFieldIR {
path: vec!["user".to_string(), "email".to_string()],
@ -281,7 +289,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
NickelFieldIR {
path: vec!["settings".to_string(), "theme".to_string()],
@ -291,7 +301,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
],
};
@ -340,7 +352,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
],
};

View File

@ -204,4 +204,53 @@ age : Number = {{ age }}
let output = result.unwrap();
assert!(output.contains("monitoring_enabled"));
}
#[cfg(feature = "templates")]
#[test]
fn test_render_values_template() {
let mut engine = TemplateEngine::new();
let mut values = HashMap::new();
// Simulate form results with typical values
values.insert("environment_name".to_string(), json!("production"));
values.insert("provider".to_string(), json!("lxd"));
values.insert("lxd_profile_name".to_string(), json!("torrust-profile"));
values.insert("ssh_private_key_path".to_string(), json!("~/.ssh/id_rsa"));
values.insert("ssh_username".to_string(), json!("torrust"));
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("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));
values.insert("enable_prometheus".to_string(), json!(false));
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 result = engine.render_file(template_path, &values);
assert!(result.is_ok(), "Template rendering failed: {:?}", result);
let output = result.unwrap();
// Verify key template content is present and correct
assert!(output.contains("validators.ValidEnvironmentName \"production\""));
assert!(output.contains("profile_name = \"torrust-profile\""));
assert!(output.contains("username = validators_username.ValidUsername \"torrust\""));
assert!(output.contains("port = validators_common.ValidPort 22"));
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\""));
// Verify it's long enough to contain the full template
assert!(output.len() > 2000, "Output too short: {} bytes", output.len());
}
}

View File

@ -0,0 +1,223 @@
//! Nickel Template Renderer
//!
//! 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 serde_json::Value;
/// Context for rendering Nickel templates
#[derive(Debug, Clone)]
pub struct NickelTemplateContext {
/// Original imports from source
pub imports: HashMap<String, String>,
/// Contract calls available for use in output
pub contracts: HashMap<String, ContractCall>,
/// Form results (field values)
pub results: HashMap<String, Value>,
/// Original source (for fallback/reference)
pub source: String,
}
impl NickelTemplateContext {
/// Create context from parsed contracts and form results
pub fn from_parsed_contracts(
parsed: ParsedContracts,
results: HashMap<String, Value>,
) -> Self {
Self {
imports: parsed.imports,
contracts: parsed.field_contracts,
results,
source: parsed.source,
}
}
/// Get a contract call for a field
pub fn get_contract(&self, module: &str, function: &str) -> Option<&ContractCall> {
let key = format!("{}.{}", module, function);
self.contracts.get(&key)
}
/// Render a value using its contract call if available
pub fn render_with_contract(&self, value: &Value, contract: &ContractCall) -> String {
let arg = Self::value_to_nickel_arg(value);
format!("{}.{} {}", contract.module, contract.function, arg)
}
/// Convert a JSON value to Nickel argument representation
fn value_to_nickel_arg(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
Value::Array(_) | Value::Object(_) => {
// For complex types, use JSON representation
value.to_string()
}
}
}
/// Generate Nickel code from results with preserved contracts
pub fn generate_user_config(&self) -> String {
let mut output = String::from("let user_config = {\n");
for (field_name, value) in &self.results {
// Try to find a contract for this field
let line = if let Some(contract) = self.find_contract_for_field(field_name) {
format!(
" {} = {},\n",
field_name,
self.render_with_contract(value, contract)
)
} else {
// Fallback to direct value rendering
format!(" {} = {},\n", field_name, Self::value_to_nickel_expr(value))
};
output.push_str(&line);
}
output.push_str("} in\n");
output
}
/// Find a contract that applies to this field
fn find_contract_for_field(&self, field_name: &str) -> Option<&ContractCall> {
self.contracts.get(field_name)
}
/// Convert a JSON value to full Nickel expression
fn value_to_nickel_expr(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
Value::Number(n) => n.to_string(),
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();
format!("[{}]", items.join(", "))
}
Value::Object(obj) => {
let items: Vec<String> = obj
.iter()
.map(|(k, v)| format!("{} = {}", k, Self::value_to_nickel_expr(v)))
.collect();
format!("{{{}}}", items.join(", "))
}
}
}
/// Render complete Nickel file with imports, user_config, and final expression
pub fn render_full_nickel(&self) -> String {
let mut output = String::new();
// Add imports
for (name, path) in &self.imports {
output.push_str(&format!("let {} = import \"{}\" in\n", name, path));
}
output.push('\n');
// Add user_config
output.push_str(&self.generate_user_config());
// Add final expression
output.push_str("user_config\n");
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[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!(42)), "42");
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]");
}
#[test]
fn test_render_with_contract() {
let mut ctx = NickelTemplateContext {
imports: HashMap::new(),
contracts: HashMap::new(),
results: HashMap::new(),
source: String::new(),
};
let contract = ContractCall {
module: "validators".to_string(),
function: "ValidPort".to_string(),
args: None,
expr: "validators.ValidPort".to_string(),
};
// Key by field name, not module.function
ctx.contracts.insert("ssh_port".to_string(), contract.clone());
let result = ctx.render_with_contract(&json!(8080), &contract);
assert!(result.contains("validators.ValidPort"));
assert!(result.contains("8080"));
}
#[test]
fn test_find_contract_by_field_name() {
let mut ctx = NickelTemplateContext {
imports: HashMap::new(),
contracts: HashMap::new(),
results: HashMap::new(),
source: String::new(),
};
// Add multiple contracts keyed by field name
let port_contract = ContractCall {
module: "validators".to_string(),
function: "ValidPort".to_string(),
args: None,
expr: "validators.ValidPort".to_string(),
};
let env_contract = ContractCall {
module: "validators".to_string(),
function: "ValidEnvironmentName".to_string(),
args: Some("\"dev\"".to_string()),
expr: "validators.ValidEnvironmentName \"dev\"".to_string(),
};
// 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);
// Verify each field gets its own validator
let port_result = ctx.find_contract_for_field("ssh_port");
assert!(port_result.is_some());
assert_eq!(port_result.unwrap().function, "ValidPort");
let env_result = ctx.find_contract_for_field("environment_name");
assert!(env_result.is_some());
assert_eq!(env_result.unwrap().function, "ValidEnvironmentName");
// Verify non-existent field returns None
let missing = ctx.find_contract_for_field("unknown_field");
assert!(missing.is_none());
}
}

View File

@ -4,9 +4,10 @@
//! into typedialog FormDefinition TOML format.
//!
//! Handles type mapping, metadata extraction, flatten/unflatten operations,
//! and semantic grouping for form UI organization.
//! 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 crate::error::Result;
use std::collections::HashMap;
@ -90,6 +91,7 @@ impl TomlGenerator {
field,
flatten_records,
field_order,
schema,
)?;
fields.push(form_field);
field_order += 1;
@ -108,11 +110,121 @@ impl TomlGenerator {
})
}
/// Generate forms with fragments
///
/// Creates multiple FormDefinition objects: one main form with includes for each fragment,
/// and separate forms for each fragment containing only its fields.
///
/// # Returns
///
/// HashMap with "main" key for main form, and fragment names for fragment forms
pub fn generate_with_fragments(
schema: &NickelSchemaIR,
flatten_records: bool,
_use_groups: bool,
) -> Result<HashMap<String, FormDefinition>> {
let mut result = HashMap::new();
// Get all fragments and ungrouped fields
let fragments = schema.fragments();
let ungrouped_fields = schema.fields_without_fragment();
// Generate main form with includes
let mut main_items = Vec::new();
let mut main_fields = Vec::new();
let mut item_order = 0;
let mut field_order = 100;
// 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;
}
}
// Add includes for each fragment
for fragment in &fragments {
item_order += 1;
main_items.push(DisplayItem {
name: format!("{}_group", fragment),
item_type: "group".to_string(),
title: Some(format_group_title(fragment)),
includes: Some(vec![format!("fragments/{}.toml", fragment)]),
group: Some(fragment.clone()),
order: item_order,
content: None,
template: None,
border_top: None,
border_bottom: None,
margin_left: None,
border_margin_left: None,
content_margin_left: None,
align: None,
when: None,
border_top_char: None,
border_top_len: None,
border_top_l: None,
border_top_r: None,
border_bottom_char: None,
border_bottom_len: None,
border_bottom_l: None,
border_bottom_r: None,
i18n: None,
});
}
// Create main form
let main_form = FormDefinition {
name: format!("{}_main", schema.name),
description: schema.description.clone(),
fields: main_fields,
items: main_items,
locale: None,
template: None,
output_template: None,
i18n_prefix: None,
display_mode: Default::default(),
};
result.insert("main".to_string(), main_form);
// Generate forms for each fragment
for fragment in &fragments {
let fragment_fields = schema.fields_by_fragment(fragment);
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)?;
fields.push(form_field);
}
let fragment_form = FormDefinition {
name: format!("{}_fragment", fragment),
description: Some(format!("Fragment: {}", fragment)),
fields,
items: Vec::new(),
locale: None,
template: None,
output_template: None,
i18n_prefix: None,
display_mode: Default::default(),
};
result.insert(fragment.clone(), fragment_form);
}
Ok(result)
}
/// Convert a single NickelFieldIR to a FieldDefinition
fn field_ir_to_definition(
field: &NickelFieldIR,
_flatten_records: bool,
order: usize,
schema: &NickelSchemaIR,
) -> Result<FieldDefinition> {
let (field_type, custom_type) = Self::nickel_type_to_field_type(&field.nickel_type)?;
@ -146,6 +258,9 @@ impl TomlGenerator {
Some(true)
};
// Infer conditional expression from contracts
let when_condition = ContractAnalyzer::infer_condition(field, schema);
Ok(FieldDefinition {
name: field.flat_name.clone(),
field_type,
@ -163,7 +278,7 @@ impl TomlGenerator {
max_date: None,
week_start: None,
order,
when: None,
when: when_condition,
i18n: None,
group: field.group.clone(),
nickel_contract: field.contract.clone(),
@ -267,7 +382,9 @@ mod tests {
default: Some(json!("Alice")),
optional: false,
contract: Some("String | std.string.NonEmpty".to_string()),
contract_call: None,
group: None,
fragment_marker: None,
},
NickelFieldIR {
path: vec!["age".to_string()],
@ -277,7 +394,9 @@ mod tests {
default: None,
optional: true,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
},
],
};
@ -313,7 +432,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: Some("user".to_string()),
fragment_marker: None,
},
NickelFieldIR {
path: vec!["settings_theme".to_string()],
@ -323,7 +444,9 @@ mod tests {
default: Some(json!("dark")),
optional: false,
contract: None,
contract_call: None,
group: Some("settings".to_string()),
fragment_marker: None,
},
],
};
@ -380,7 +503,9 @@ mod tests {
default: None,
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
};
let options = TomlGenerator::extract_enum_options(&field);
@ -404,10 +529,18 @@ mod tests {
default: Some(json!(42)),
optional: false,
contract: None,
contract_call: None,
group: None,
fragment_marker: None,
};
let form_field = TomlGenerator::field_ir_to_definition(&field, false, 0).unwrap();
let schema = NickelSchemaIR {
name: "test".to_string(),
description: None,
fields: vec![field.clone()],
};
let form_field = TomlGenerator::field_ir_to_definition(&field, false, 0, &schema).unwrap();
assert_eq!(form_field.default, Some("42".to_string()));
}
}

View File

@ -3,13 +3,14 @@
//! A terminal UI (TUI) tool for creating interactive forms with enhanced visual presentation.
//! Uses ratatui for advanced terminal rendering capabilities.
use clap::Parser;
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;
@ -23,8 +24,11 @@ use unic_langid::LanguageIdentifier;
long_about = cli_common::TUI_MAIN_LONG_ABOUT
)]
struct Args {
/// Path to TOML form configuration file
config: PathBuf,
#[command(subcommand)]
command: Option<Commands>,
/// Path to TOML form configuration file (for default form command)
config: Option<PathBuf>,
/// Output format: json, yaml, toml, or text
#[arg(short, long, default_value = "text", help = cli_common::FORMAT_FLAG_HELP)]
@ -39,18 +43,94 @@ struct Args {
locale: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
/// Execute interactive form from TOML configuration
Form {
/// Path to TOML form configuration file
config: PathBuf,
/// Path to JSON file with default field values
#[arg(long)]
defaults: Option<PathBuf>,
},
/// Complete roundtrip: .ncl → form → .ncl with preserved validators
#[command(name = "nickel-roundtrip")]
NickelRoundtrip {
/// Path to input Nickel file (.ncl)
input: PathBuf,
/// Path to form definition (.toml)
form: PathBuf,
/// Path to output Nickel file (.ncl)
#[arg(long)]
output: PathBuf,
/// Optional Tera template file (.ncl.j2) for rendering output
#[arg(long)]
ncl_template: Option<PathBuf>,
/// Disable validation with nickel typecheck
#[arg(long)]
no_validate: bool,
/// Print verbose progress messages
#[arg(short, long)]
verbose: bool,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
execute_form(args.config, &args.format, &args.out, &args.locale).await
match args.command {
Some(Commands::Form { config, defaults }) => {
execute_form(config, defaults, &args.format, &args.out, &args.locale).await?;
}
Some(Commands::NickelRoundtrip {
input,
form,
output,
ncl_template,
no_validate,
verbose,
}) => {
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.format, &args.out, &args.locale).await?;
}
}
Ok(())
}
async fn execute_form(config: PathBuf, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
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)?;
// Extract base directory for resolving relative paths in includes
let base_dir = config
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
// Load default values from JSON 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)))?;
Some(defaults_json)
} else {
None
};
// Load I18nBundle if needed
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
let config = TypeDialogConfig::default();
@ -67,9 +147,9 @@ async fn execute_form(config: PathBuf, format: &str, output_file: &Option<PathBu
let mut backend = BackendFactory::create(BackendType::Tui)?;
let results = if let Some(ref bundle) = i18n_bundle {
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).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(form, backend.as_mut()).await?
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), None, base_dir, initial_values).await?
};
print_results(&results, format, output_file)?;
@ -91,3 +171,45 @@ fn print_results(
Ok(())
}
async fn nickel_roundtrip_cmd(
input: PathBuf,
form: PathBuf,
output: PathBuf,
ncl_template: Option<PathBuf>,
validate: bool,
verbose: bool,
) -> Result<()> {
if verbose {
eprintln!("Starting Nickel roundtrip workflow with TUI 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()?;
if verbose {
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
}
// 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());
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"));
}
}
Ok(())
}

View File

@ -3,12 +3,13 @@
//! A web server tool for creating interactive forms accessible via HTTP.
//! Uses axum web framework with HTMX for dynamic form interactions.
use clap::Parser;
use clap::{Parser, Subcommand};
use typedialog_core::{form_parser, Error, Result};
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 unic_langid::LanguageIdentifier;
@ -21,8 +22,11 @@ use unic_langid::LanguageIdentifier;
long_about = cli_common::WEB_MAIN_LONG_ABOUT
)]
struct Args {
/// Path to TOML form configuration file
config: PathBuf,
#[command(subcommand)]
command: Option<Commands>,
/// Path to TOML form configuration file (for default web command)
config: Option<PathBuf>,
/// Port to listen on (can also be set via TYPEDIALOG_PORT env var)
#[arg(short, long, default_value = "8080")]
@ -33,18 +37,94 @@ struct Args {
locale: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
/// Execute interactive form from TOML configuration
Form {
/// Path to TOML form configuration file
config: PathBuf,
/// Path to JSON file with default field values
#[arg(long)]
defaults: Option<PathBuf>,
},
/// Complete roundtrip: .ncl → form → .ncl with preserved validators
#[command(name = "nickel-roundtrip")]
NickelRoundtrip {
/// Path to input Nickel file (.ncl)
input: PathBuf,
/// Path to form definition (.toml)
form: PathBuf,
/// Path to output Nickel file (.ncl)
#[arg(long)]
output: PathBuf,
/// Optional Tera template file (.ncl.j2) for rendering output
#[arg(long)]
ncl_template: Option<PathBuf>,
/// Disable validation with nickel typecheck
#[arg(long)]
no_validate: bool,
/// Print verbose progress messages
#[arg(short, long)]
verbose: bool,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
execute_form(args.config, args.port, &args.locale).await
match args.command {
Some(Commands::Form { config, defaults }) => {
execute_form(config, defaults, args.port, &args.locale).await?;
}
Some(Commands::NickelRoundtrip {
input,
form,
output,
ncl_template,
no_validate,
verbose,
}) => {
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?;
}
}
Ok(())
}
async fn execute_form(config: PathBuf, port: u16, cli_locale: &Option<String>) -> Result<()> {
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)?;
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("."));
// Load default values from JSON 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)))?;
Some(defaults_json)
} else {
None
};
// Load I18nBundle if needed
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
let config = TypeDialogConfig::default();
@ -65,10 +145,52 @@ async fn execute_form(config: PathBuf, port: u16, cli_locale: &Option<String>) -
println!("Listening on http://localhost:{}", port);
let _results = if let Some(ref bundle) = i18n_bundle {
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).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(form, backend.as_mut()).await?
form_parser::execute_with_backend_two_phase_with_defaults(form, backend.as_mut(), None, base_dir, initial_values).await?
};
Ok(())
}
async fn nickel_roundtrip_cmd(
input: PathBuf,
form: 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()?;
if verbose {
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
}
// 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());
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"));
}
}
Ok(())
}

View File

@ -7,7 +7,7 @@ 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};
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;
@ -170,6 +170,10 @@ enum Commands {
/// Optional path to Nickel template (.ncl.j2) for direct Nickel generation
#[arg(value_name = "TEMPLATE")]
template: Option<PathBuf>,
/// Path to JSON file with default field values
#[arg(long)]
defaults: Option<PathBuf>,
},
/// Convert Nickel schema to TOML form
@ -189,6 +193,22 @@ enum Commands {
/// 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
@ -216,6 +236,33 @@ enum Commands {
/// Path to results JSON file
results: PathBuf,
},
/// Complete roundtrip: .ncl → form → .ncl with preserved validators
#[command(name = "nickel-roundtrip")]
NickelRoundtrip {
/// Path to input Nickel file (.ncl)
input: PathBuf,
/// Path to form definition (.toml)
form: PathBuf,
/// Path to output Nickel file (.ncl)
#[arg(long)]
output: PathBuf,
/// Optional Jinja2 template file (.ncl.j2) for rendering output
/// If provided, renders template with form results instead of parsing contracts
#[arg(long)]
ncl_template: Option<PathBuf>,
/// Disable validation with nickel typecheck
#[arg(long)]
no_validate: bool,
/// Print verbose progress messages
#[arg(short, long)]
verbose: bool,
},
}
#[tokio::main]
@ -295,8 +342,8 @@ async fn main() -> Result<()> {
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Form { config, template } => {
execute_form(config, template, &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 {
@ -304,8 +351,12 @@ async fn main() -> Result<()> {
current_data,
flatten,
groups,
fragments,
conditionals,
i18n,
output,
} => {
nickel_to_form_cmd(schema, current_data, &cli.out, flatten, groups)?;
nickel_to_form_cmd(schema, current_data, flatten, groups, fragments, conditionals, i18n, output)?;
}
Commands::FormToNickel {
@ -322,17 +373,44 @@ async fn main() -> Result<()> {
} => {
nickel_template_cmd(template, results, &cli.out)?;
}
Commands::NickelRoundtrip {
input,
form,
output,
ncl_template,
no_validate,
verbose,
} => {
nickel_roundtrip_cmd(input, form, output, ncl_template, !no_validate, verbose)?;
}
}
Ok(())
}
async fn execute_form(config: PathBuf, template: Option<PathBuf>, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
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("."));
// Load default values from JSON 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)))?;
Some(defaults_json)
} else {
None
};
// Load I18nBundle if needed
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
// Resolve locale: CLI flag > form locale > env var > default
@ -356,11 +434,11 @@ async fn execute_form(config: PathBuf, template: Option<PathBuf>, format: &str,
let backend_type = BackendFactory::auto_detect();
let mut backend = BackendFactory::create(backend_type)?;
// Execute form with i18n support
// 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_i18n(form, backend.as_mut(), Some(bundle)).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(form, backend.as_mut()).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
@ -429,12 +507,16 @@ fn print_results(
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn nickel_to_form_cmd(
schema: PathBuf,
_current_data: Option<PathBuf>,
output: &Option<PathBuf>,
flatten: bool,
groups: bool,
fragments: bool,
_conditionals: bool, // Conditionals are auto-generated by ContractAnalyzer in TomlGenerator
i18n: bool,
output_dir: Option<PathBuf>,
) -> Result<()> {
// Verify nickel CLI is available
NickelCli::verify()?;
@ -443,21 +525,72 @@ fn nickel_to_form_cmd(
let metadata = NickelCli::query(schema.as_path(), Some("inputs"))?;
// Parse into intermediate representation
let schema_ir = MetadataParser::parse(metadata)?;
let mut schema_ir = MetadataParser::parse(metadata)?;
// Generate TOML form
let form_def = TomlGenerator::generate(&schema_ir, flatten, groups)?;
// 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);
}
// Serialize to TOML
let toml_output = ::toml::to_string_pretty(&form_def)
.map_err(|e| Error::validation_failed(e.to_string()))?;
// Write output
if let Some(path) = output {
fs::write(path, &toml_output).map_err(Error::io)?;
println!("Form written to {}", path.display());
// 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 {
println!("{}", toml_output);
// 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(())
@ -530,3 +663,47 @@ fn nickel_template_cmd(
Ok(())
}
fn nickel_roundtrip_cmd(
input: PathBuf,
form: PathBuf,
output: PathBuf,
ncl_template: Option<PathBuf>,
validate: bool,
verbose: bool,
) -> Result<()> {
use typedialog_core::nickel::RoundtripConfig;
if verbose {
eprintln!("Starting Nickel roundtrip workflow");
}
// 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()?;
if verbose {
eprintln!("[roundtrip] Generated {} bytes", result.output_nickel.len());
}
// 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());
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"));
}
}
Ok(())
}

View File

@ -211,6 +211,19 @@ just ci::build-release # Release build
just ci::full # Complete pipeline
```
### nickel - Nickel Schema Generation
Generate TOML forms from Nickel schemas:
```bash
just nickel::nickel-to-form SCHEMA # Generate form from schema
just nickel::nickel-preview SCHEMA # Preview without writing
just nickel::nickel-workflow SCHEMA TEMPLATE # Full workflow
just nickel::nickel-i18n SCHEMA # Extract i18n only
just nickel::nickel-roundtrip IN FORM # Roundtrip (read/write)
just nickel::help # Show Nickel recipes help
```
### distro - Distribution & Packaging
Build, package, and release:
@ -326,7 +339,26 @@ cargo run -p typedialog-tui --example form_with_autocompletion
cargo run -p typedialog-web -- --config config/web/dev.toml
```
See [../examples/README.md](../examples/README.md) for complete guide.
### Nickel Examples
Nickel schema parsing and generation:
```bash
# Basic Nickel schema
cargo run --example basic_nickel_schema
# Roundtrip (parse, transform, write)
cargo run --example nickel_roundtrip
# I18n extraction from Nickel
cargo run --example nickel_i18n_extraction
# Template rendering
cargo run --example nickel_template_context
```
See [../examples/07-nickel-generation/](../examples/07-nickel-generation/) for complete guide.
See [NICKEL.md](NICKEL.md) for detailed Nickel documentation.
## Troubleshooting

479
docs/NICKEL.md Normal file
View File

@ -0,0 +1,479 @@
<div align="center">
<img src="../imgs/typedialog_logo_h_s.svg" alt="TypeDialog Logo" width="600" />
</div>
# Nickel Schema Support
Full support for type-safe form schemas using the Nickel language.
## Overview
TypeDialog integrates Nickel for:
- **Type-safe schemas** - Validate form structure at parse time
- **Contract validation** - Enforce type and content contracts
- **I18n extraction** - Auto-extract translatable strings
- **Template rendering** - Generate forms via Tera templates
- **Roundtrip support** - Idempotent read/write transformations
## Quick Start
### Prerequisites
Install Nickel CLI (optional, for schema validation):
```bash
cargo install nickel-lang
# Or download from: https://github.com/nickel-lang/nickel/releases
```
### Run Examples
```bash
# List all Nickel examples
just test::list | grep nickel
# Run basic Nickel schema
cargo run --example basic_nickel_schema
# Run with roundtrip (read, transform, write)
cargo run --example nickel_roundtrip
# Run with i18n extraction
cargo run --example nickel_i18n_extraction
```
See [examples/07-nickel-generation/](../examples/07-nickel-generation/) for complete examples.
## Core Modules
### contract_parser
Parse and validate Nickel contracts.
```rust
use typedialog_core::nickel::ContractParser;
let parser = ContractParser::new();
let contracts = parser.parse(nickel_schema)?;
```
**Validates:**
- Type correctness
- Field constraints
- Required vs optional fields
- Custom predicates
### i18n_extractor
Automatically extract translatable strings from Nickel schemas.
```rust
use typedialog_core::nickel::I18nExtractor;
let extractor = I18nExtractor::new();
let strings = extractor.extract(nickel_ast)?;
// Output: Map of translatable strings with locations
```
**Extracts:**
- Field labels
- Help text
- Error messages
- Custom metadata
### template_renderer
Render Nickel schemas using Tera templates.
```rust
use typedialog_core::nickel::NickelTemplateContext;
let context = NickelTemplateContext::from_schema(schema)?;
let rendered = template_engine.render("form.j2", &context)?;
```
**Supports:**
- Tera template syntax
- Schema introspection
- Custom filters
- Conditional rendering
### roundtrip
Idempotent read/write for Nickel schemas.
```rust
use typedialog_core::nickel::RoundtripConfig;
let config = RoundtripConfig::default();
let schema = parser.parse_file("form.ncl")?;
let modified = transform(schema)?;
serializer.write_file("form.ncl", &modified, &config)?;
```
**Preserves:**
- Comments
- Formatting
- Import structure
- Custom layouts
## Schema Structure
### Basic Form
```nicl
{
title = "User Registration",
fields = {
name = {
type = "text",
label = "Full Name",
required = true,
},
email = {
type = "email",
label = "Email Address",
required = true,
},
}
}
```
### With Contracts
```nicl
{
fields = {
password = {
type = "text",
label = "Password",
contracts = [
{ predicate = |s| string.length s >= 8, error = "Min 8 chars" },
{ predicate = |s| string.contains s "!", error = "Need special char" },
]
}
}
}
```
### With I18n
```nicl
{
i18n = "en-US",
fields = {
name = {
label = "i18n:form.fields.name",
help = "i18n:form.fields.name.help",
}
}
}
```
### With Templates
```nicl
{
template = "form.j2",
context = {
theme = "dark",
layout = "grid",
},
fields = { ... }
}
```
## Building with Nickel
Build project with Nickel support:
```bash
# Build (includes Nickel feature by default)
just build::default
# Test Nickel modules
just test::core
# Generate docs
just dev::docs
```
## Examples
Complete examples in [examples/07-nickel-generation/](../examples/07-nickel-generation/):
### Schemas
- **simple.ncl** - Basic form schema
- **complex.ncl** - Advanced with sections and groups
- **conditional.ncl** - Conditional field visibility
- **i18n.ncl** - Internationalization example
### Templates
- **config.ncl.j2** - Configuration form template
- **deployment.ncl.j2** - Deployment spec template
- **service_spec.ncl.j2** - Service specification template
### Usage
```bash
cd examples/07-nickel-generation/
# Validate schema
nickel export schemas/simple.ncl
# Parse and render
cargo run --manifest-path ../../Cargo.toml \
--example nickel_form_generation < schemas/simple.ncl
```
## Integration Patterns
### CLI Backend
```bash
typedialog --format nickel form.ncl
```
### TUI Backend
```bash
typedialog-tui form.ncl
```
### Web Backend
```bash
typedialog-web --config form.ncl
```
## Advanced Topics
### Custom Contracts
Define custom validation predicates:
```nicl
{
fields = {
age = {
type = "number",
contracts = [
{
predicate = |n| n >= 18,
error = "Must be 18+"
}
]
}
}
}
```
### Reusable Components
```nicl
let address_fields = {
street = { type = "text", label = "Street" },
city = { type = "text", label = "City" },
zip = { type = "text", label = "ZIP" },
};
{
fields = address_fields & {
country = { type = "select", label = "Country" }
}
}
```
### Schema Inheritance
Extend base schemas:
```nicl
let base = import "schemas/base.ncl";
base & {
fields = base.fields & {
custom_field = { ... }
}
}
```
### I18n Management
Extract and manage translations:
```bash
# Extract all translatable strings
cargo run --example nickel_i18n_extraction < form.ncl > strings.json
# Update translations
# (use with translation management system)
```
## Performance Considerations
### Parsing
- Schemas parsed once at startup
- AST cached in memory
- Validation is O(n) in field count
### Rendering
- Templates compiled once
- Context generation O(n)
- Output streamed for large forms
### Roundtrip
- Preserves source formatting
- Minimal allocations
- In-place transformations
## Troubleshooting
### Schema validation fails
Check syntax with Nickel CLI:
```bash
nickel export form.ncl
```
### Contract errors
Verify predicates are boolean functions:
```nicl
# ❌ Wrong
contracts = [
{ predicate = |s| string.length s, error = "..." }
]
# ✓ Correct
contracts = [
{ predicate = |s| string.length s >= 8, error = "..." }
]
```
### Template rendering fails
Ensure Tera template exists and context matches:
```bash
# Verify template
ls -la templates/form.j2
# Check context keys
cargo run --example nickel_template_context < form.ncl
```
### I18n strings not extracted
Verify string format:
```nicl
# ❌ Plain string
label = "Name"
# ✓ I18n reference
label = "i18n:form.name"
```
## Best Practices
1. **Schema Organization**
- Keep schemas modular
- Use `import` for shared components
- Version schemas in git
2. **Contracts**
- Keep predicates simple
- Provide clear error messages
- Test edge cases
3. **I18n**
- Use consistent key naming
- Extract before translations
- Validate all strings are referenced
4. **Templates**
- Keep templates simple
- Use filters for formatting
- Test with multiple data types
5. **Roundtrip**
- Always test read/write cycles
- Preserve comments with care
- Validate output structure
## API Reference
### ContractParser
```rust
pub struct ContractParser;
impl ContractParser {
pub fn new() -> Self;
pub fn parse(&self, source: &str) -> Result<ParsedContracts>;
}
pub struct ParsedContracts {
pub fields: Map<String, FieldContract>,
pub global: Vec<Contract>,
}
```
### I18nExtractor
```rust
pub struct I18nExtractor;
impl I18nExtractor {
pub fn new() -> Self;
pub fn extract(&self, ast: &NickelAst) -> Result<I18nMap>;
}
```
### NickelTemplateContext
```rust
pub struct NickelTemplateContext {
pub schema: NickelSchemaIR,
pub fields: Map<String, FieldContext>,
}
impl NickelTemplateContext {
pub fn from_schema(schema: NickelSchemaIR) -> Result<Self>;
}
```
### RoundtripConfig
```rust
pub struct RoundtripConfig {
pub preserve_comments: bool,
pub preserve_formatting: bool,
pub indent: String,
}
impl Default for RoundtripConfig {
fn default() -> Self {
Self {
preserve_comments: true,
preserve_formatting: true,
indent: " ".to_string(),
}
}
}
```
## Next Steps
- [DEVELOPMENT.md](DEVELOPMENT.md) - Build and test
- [CONFIGURATION.md](CONFIGURATION.md) - Backend configuration
- [Examples](../examples/07-nickel-generation/) - Complete examples
- [Nickel Language](https://nickel-lang.org) - Language reference
---
**Latest Update**: December 2024
**Status**: Stable

View File

@ -47,6 +47,10 @@ Complete documentation for using, building, and deploying TypeDialog.
- [Installing just](INSTALLATION.md#just-command-orchestration)
- [Installing Nickel CLI](INSTALLATION.md#nickel-cli--optional)
### Nickel Support
- [Nickel Guide](NICKEL.md) - Schema parsing, contracts, i18n, templates
- [Nickel Examples](../examples/07-nickel-generation/) - Complete examples
### Development
- [Quick Start Development](DEVELOPMENT.md#quick-start)
- [Using just Commands](DEVELOPMENT.md#just-commands)
@ -110,6 +114,19 @@ Complete working examples are in the `examples/` directory:
**...cross-compile for other platforms**
→ Read [BUILD.md](BUILD.md#cross-compilation)
## Documentation Navigation
```
docs/
├── README.md ← Overview (you are here)
├── INSTALLATION.md ← Setup & requirements
├── DEVELOPMENT.md ← Local development & `just` commands
├── BUILD.md ← Building & distribution
├── RELEASE.md ← Release workflow
├── CONFIGURATION.md ← Backend configuration
└── NICKEL.md ← Nickel schema support (NEW)
```
## Documentation Structure
This documentation follows a **layered approach**:
@ -123,4 +140,4 @@ See [Architecture](../README.md#architecture) for project structure.
---
**Latest Update**: December 2024
**Status**: Complete
**Status**: Complete (+ Nickel support)

View File

@ -0,0 +1,468 @@
# Nickel Schema to TOML Form Generation Examples
This directory contains complete, runnable examples of the TypeDialog Nickel integration workflow:
```
Nickel Schema → TOML Form → User Input → JSON Results → Nickel Output
```
## Quick Start
### Example 1: Simple Schema (Learning)
Generate a TOML form from a basic Nickel schema:
```bash
# Generate form
just nickel::nickel-to-form schemas/simple.ncl generated/simple/
# Execute form interactively
typedialog form generated/simple/form.toml -o results/simple_results.json
# View results
cat results/simple_results.json | jq .
```
**What it demonstrates**:
- Basic type mapping (String, Number, Bool)
- Doc comment extraction
- Default value handling
- Simple form generation
### Example 2: Complex Schema (Fragmentation)
Generate fragmented forms from a schema with multiple sections:
```bash
# Generate with automatic fragmentation
just nickel::nickel-to-form schemas/complex.ncl generated/complex/
# List generated files
ls -la generated/complex/
ls -la generated/complex/fragments/
# Execute main form
typedialog form generated/complex/main_form.toml -o results/complex_results.json
```
**What it demonstrates**:
- Fragment markers (`# @fragment: name`)
- Multi-file form composition
- Fragment includes in main form
- Large schema organization
**Generated structure**:
```
generated/complex/
├── main_form.toml # Form with includes
└── fragments/
├── app_config.toml
├── server_config.toml
├── database_config.toml
└── cache_config.toml
```
### Example 3: Conditional Fields (Smart Visibility)
Generate conditionals automatically from optional fields:
```bash
# Generate with conditionals
just nickel::nickel-to-form schemas/conditional.ncl generated/conditional/
# View generated conditionals
grep -A 2 'when =' generated/conditional/form.toml
# Execute form - notice conditional visibility
typedialog form generated/conditional/form.toml
```
**What it demonstrates**:
- Optional field handling
- Automatic conditional generation from contracts
- Boolean dependency detection
- Dynamic form visibility
**Example generated conditionals**:
```toml
[[fields]]
name = "tls_cert_path"
when = "tls_enabled == true" # Auto-generated!
[[fields]]
name = "database_url"
when = "database_enabled == true" # Auto-generated!
```
### Example 4: i18n Extraction (Multi-Language)
Generate Fluent translation files from schema:
```bash
# Extract i18n only
just nickel::nickel-i18n schemas/i18n.ncl generated/i18n/
# View generated translations
ls -la generated/i18n/locales/
cat generated/i18n/locales/en/forms.ftl
cat generated/i18n/locales/es/forms.ftl
# Execute with Spanish locale
LOCALE=es typedialog form generated/i18n/form.toml
```
**What it demonstrates**:
- Multi-language doc comment parsing
- Fluent (.ftl) file generation
- Locale-specific form generation
- Translation key mapping
**Generated translations**:
```fluent
# en/forms.ftl
app-name-prompt = Application name
app-name-help = Enter the application display name
# es/forms.ftl
app-name-prompt = Nombre de la aplicación
app-name-help = Ingrese el nombre de la aplicación
```
### Example 5: Full Workflow (Schema → Results → Nickel)
Complete workflow: generate form, execute with results, render Nickel output:
```bash
# Full workflow: schema → form → results → Nickel
just nickel::nickel-workflow \
schemas/simple.ncl \
templates/config.ncl.j2 \
generated/output_config.ncl
# Validate generated Nickel
nickel typecheck generated/output_config.ncl
# View generated configuration
cat generated/output_config.ncl
```
**What it demonstrates**:
- End-to-end workflow automation
- Form execution with user input
- Template rendering with results
- Nickel validation
- Configuration generation
## Directory Structure
```
examples/07-nickel-generation/
├── README.md # This file
├── schemas/ # Nickel schema files
│ ├── simple.ncl # Basic types only
│ ├── complex.ncl # With fragment markers
│ ├── conditional.ncl # With optional fields + boolean deps
│ └── i18n.ncl # With English/Spanish docs
├── templates/ # Jinja2 templates for rendering
│ ├── config.ncl.j2 # Basic config template
│ ├── service_spec.ncl.j2 # Kubernetes service template
│ └── deployment.ncl.j2 # Kubernetes deployment template
├── generated/ # Example outputs (reference)
│ ├── simple/
│ │ └── form.toml
│ ├── complex/
│ │ ├── main_form.toml
│ │ └── fragments/
│ ├── conditional/
│ │ └── form.toml
│ └── i18n/
│ ├── form.toml
│ └── locales/
└── results/ # Example results (reference)
└── simple_results.json
```
## Schema Features Explained
### Fragment Markers
Mark sections for automatic splitting:
```nickel
# @fragment: database_config
database = { ... }
```
Generates separate file: `fragments/database_config.toml`
**When to use**:
- Schemas with 20+ fields
- Logical section boundaries
- Independent configuration areas
### Optional Fields with Dependencies
Automatically generate conditionals:
```nickel
tls_enabled | doc "Enable TLS" : Bool,
tls_cert | optional
| doc "Certificate path"
: String,
```
Generates:
```toml
[[fields]]
name = "tls_cert"
when = "tls_enabled == true"
```
**Pattern**: `<feature>_enabled` → dependent fields
### Multi-Language Docs
Support multiple languages in doc comments:
```nickel
field_name
| doc "English text"
| doc.es "Texto en español"
: String
```
Generates `.ftl` files for each locale with translations
## Templates
### config.ncl.j2
Basic application configuration template:
- Renders app metadata
- Server configuration
- Database connection (optional)
- Cache settings (conditional)
**Usage**:
```bash
just nickel::nickel-workflow \
schemas/simple.ncl \
templates/config.ncl.j2 \
config.ncl
```
### service_spec.ncl.j2
Kubernetes service specification:
- Service metadata
- Port configuration
- Selector rules
- Service type (ClusterIP, NodePort, LoadBalancer)
**Usage**:
```bash
typedialog form <form> -o results.json
typedialog nickel-template templates/service_spec.ncl.j2 results.json -o service.ncl
nickel export service.ncl
```
### deployment.ncl.j2
Kubernetes deployment configuration:
- Deployment metadata
- Pod replica count
- Container specification
- Resource limits and requests
- Health checks (liveness, readiness probes)
- Environment variables
**Usage**:
```bash
just nickel::nickel-workflow \
<schema> \
templates/deployment.ncl.j2 \
deployment.ncl
```
## Common Workflows
### Preview Form Before Generation
```bash
# Preview without writing files
just nickel::nickel-preview schemas/complex.ncl
# Then generate with full options
just nickel::nickel-to-form schemas/complex.ncl generated/complex/
```
### Extract i18n Only
```bash
# Generate only translation files
just nickel::nickel-i18n schemas/i18n.ncl translations/
# Review translations
cat translations/locales/en/forms.ftl
cat translations/locales/es/forms.ftl
```
### Render Template with Sample Data
```bash
# Use example results to render template
typedialog nickel-template templates/config.ncl.j2 \
results/simple_results.json \
-o sample_output.ncl
# Review output
cat sample_output.ncl
```
### Full Interactive Workflow
```bash
# 1. Generate form
just nickel::nickel-to-form schemas/simple.ncl /tmp/form_gen/
# 2. Execute form interactively
typedialog form /tmp/form_gen/form.toml -o /tmp/results.json
# 3. Review results
cat /tmp/results.json | jq .
# 4. Render template
typedialog nickel-template templates/config.ncl.j2 \
/tmp/results.json \
-o /tmp/generated_config.ncl
# 5. Validate Nickel
nickel typecheck /tmp/generated_config.ncl
# 6. Export to JSON
nickel export /tmp/generated_config.ncl
```
## Testing Examples
### Run All Examples
```bash
#!/bin/bash
EXAMPLES_DIR="examples/07-nickel-generation"
# Test 1: Simple
echo "Testing simple schema..."
just nickel::nickel-to-form "$EXAMPLES_DIR/schemas/simple.ncl" /tmp/test_simple
[ -f /tmp/test_simple/form.toml ] && echo "✓ Simple form generated"
# Test 2: Complex
echo "Testing complex schema..."
just nickel::nickel-to-form "$EXAMPLES_DIR/schemas/complex.ncl" /tmp/test_complex
[ -f /tmp/test_complex/main_form.toml ] && echo "✓ Complex form generated"
[ -d /tmp/test_complex/fragments ] && echo "✓ Fragments created"
# Test 3: Conditional
echo "Testing conditional schema..."
just nickel::nickel-to-form "$EXAMPLES_DIR/schemas/conditional.ncl" /tmp/test_conditional
grep -q "when =" /tmp/test_conditional/form.toml && echo "✓ Conditionals generated"
# Test 4: i18n
echo "Testing i18n schema..."
just nickel::nickel-i18n "$EXAMPLES_DIR/schemas/i18n.ncl" /tmp/test_i18n
[ -f /tmp/test_i18n/locales/en/forms.ftl ] && echo "✓ English translations generated"
[ -f /tmp/test_i18n/locales/es/forms.ftl ] && echo "✓ Spanish translations generated"
echo ""
echo "All tests passed!"
```
## Extending Examples
### Adding New Schemas
1. Create `schemas/my_schema.ncl` with Nickel configuration
2. Add fragment markers (`# @fragment: name`) for sections
3. Use optional fields for conditionals
4. Use multi-language docs for i18n
```nickel
{
# @fragment: my_section
my_field | doc "English" | doc.es "Español" : String,
feature_enabled | doc "Enable feature" : Bool = false,
feature_config | optional
| doc "Feature configuration"
: String,
}
```
### Adding New Templates
1. Create `templates/my_template.ncl.j2`
2. Use Jinja2 syntax for conditionals and loops
3. Reference form result variables
4. Test with sample data
```jinja2
{
config = {
{% for item in items %}
{{ item.name }} : String = "{{ item.value }}",
{% endfor %}
},
}
```
## Troubleshooting
### Form Generation Fails
```bash
# Verify schema syntax
nickel typecheck schemas/simple.ncl
# Check for schema-specific issues
nickel query schemas/simple.ncl
```
### Fragment Includes Not Found
```bash
# Verify fragments directory exists
ls -la generated/complex/fragments/
# Check include paths in main form
grep includes generated/complex/main_form.toml
```
### Conditionals Not Generated
```bash
# Verify optional fields exist
grep -A 2 "optional" schemas/conditional.ncl
# Check for boolean enable flags
grep "_enabled" schemas/conditional.ncl
```
### i18n Not Working
```bash
# Verify locale directories created
find generated/i18n/locales/ -name "*.ftl"
# Check .ftl file format
cat generated/i18n/locales/en/forms.ftl | head
```
## See Also
- `.claude/guidelines/typedialog.md` - Complete TypeDialog guide
- `templates/nickel/README.md` - Template library documentation
- `just nickel::help` - All available recipes
- `/nickel-gen` - Interactive slash command

View File

@ -0,0 +1,34 @@
# Complex Nickel schema with fragment markers
# Demonstrates automatic fragmentation into multiple TOML files
{
# @fragment: app_config
app = {
name | doc "Application name" : String,
version | doc "Application version" : String = "1.0.0",
description | doc "Application description" : String = "",
},
# @fragment: server_config
server = {
host | doc "Server host address" : String = "0.0.0.0",
port | doc "Server port number" : Number = 8080,
timeout | doc "Request timeout in seconds" : Number = 30,
max_connections | doc "Maximum concurrent connections" : Number = 100,
},
# @fragment: database_config
database = {
url | doc "Database connection URL" : String,
pool_size | doc "Connection pool size" : Number = 10,
max_retries | doc "Maximum reconnection attempts" : Number = 3,
ssl_enabled | doc "Enable SSL for database connection" : Bool = false,
},
# @fragment: cache_config
cache = {
enabled | doc "Enable caching layer" : Bool = true,
backend | doc "Cache backend type" : String = "memory",
ttl | doc "Cache time-to-live in seconds" : Number = 3600,
},
}

View File

@ -0,0 +1,50 @@
# Conditional Nickel schema demonstrating optional fields with dependencies
# Shows how ContractAnalyzer automatically generates 'when' expressions
{
# Feature toggles with optional dependent fields
# TLS Configuration
tls_enabled | doc "Enable TLS for secure connections" : Bool = false,
tls_cert_path | optional
| doc "Path to TLS certificate file"
: String,
tls_key_path | optional
| doc "Path to TLS private key file"
: String,
# Database Configuration
database_enabled | doc "Enable database" : Bool = true,
database_url | optional
| doc "Database connection URL"
: String,
database_pool_size | optional
| doc "Database connection pool size"
: Number = 10,
# Authentication Configuration
auth_enabled | doc "Enable authentication" : Bool = false,
auth_provider | optional
| doc "Authentication provider (oauth, jwt, basic)"
: String,
auth_secret | optional
| doc "Authentication secret key"
: String,
# Logging Configuration
logging_enabled | doc "Enable application logging" : Bool = true,
logging_level | optional
| doc "Logging level (debug, info, warn, error)"
: String = "info",
logging_file | optional
| doc "Log file path (leave empty for stdout)"
: String,
}

View File

@ -0,0 +1,49 @@
# i18n Nickel schema with English and Spanish documentation
# Demonstrates multi-language form generation with Fluent
{
app_name
| doc "Application name"
| doc.es "Nombre de la aplicación"
: String,
app_version
| doc "Application version"
| doc.es "Versión de la aplicación"
: String = "1.0.0",
server_host
| doc "Server host address"
| doc.es "Dirección del host del servidor"
: String = "0.0.0.0",
server_port
| doc "Server port number"
| doc.es "Número de puerto del servidor"
: Number = 8080,
database_url
| doc "Database connection URL"
| doc.es "URL de conexión a la base de datos"
: String,
enable_caching
| doc "Enable caching layer"
| doc.es "Habilitar capa de caché"
: Bool = true,
cache_ttl
| doc "Cache time-to-live in seconds"
| doc.es "Tiempo de vida del caché en segundos"
: Number = 3600,
admin_email
| doc "Administrator email address"
| doc.es "Dirección de correo del administrador"
: String,
enable_notifications
| doc "Enable email notifications"
| doc.es "Habilitar notificaciones por correo"
: Bool = false,
}

View File

@ -0,0 +1,15 @@
# Simple Nickel schema demonstrating basic type mapping
# No fragments, no conditionals - useful for learning TypeDialog basics
{
app = {
name | doc "Application name" : String,
version | doc "Application version" : String = "1.0.0",
},
server = {
host | doc "Server host address" : String = "0.0.0.0",
port | doc "Server port number" : Number = 8080,
debug_mode | doc "Enable debug mode" : Bool = false,
},
}

View File

@ -0,0 +1,43 @@
# Generated Nickel configuration from form results
# This template renders form data into a valid Nickel configuration
{
# Application Configuration
app = {
name | doc "Application name" : String = "{{ app_name }}",
{%- if app_version is defined %}
version | doc "Version" : String = "{{ app_version }}",
{%- else %}
version : String = "1.0.0",
{%- endif %}
},
# Server Configuration
server = {
host | doc "Server host" : String = "{{ server_host | default('0.0.0.0') }}",
port | doc "Server port" : Number = {{ server_port | default(8080) }},
{%- if timeout is defined %}
timeout | doc "Request timeout" : Number = {{ timeout }},
{%- endif %}
},
{%- if database_url is defined %}
# Database Configuration
database = {
url | doc "Database URL" : String = "{{ database_url }}",
{%- if database_pool_size is defined %}
pool_size | doc "Connection pool size" : Number = {{ database_pool_size }},
{%- endif %}
},
{%- endif %}
{%- if enable_caching == "true" or enable_caching == true %}
# Cache Configuration
cache = {
enabled | doc "Caching enabled" : Bool = true,
{%- if cache_ttl is defined %}
ttl | doc "Cache TTL" : Number = {{ cache_ttl }},
{%- endif %}
},
{%- endif %}
}

View File

@ -0,0 +1,120 @@
# Deployment Configuration template
# Renders form data into a Nickel deployment specification
{
# Deployment Metadata
metadata = {
name | doc "Deployment name" : String = "{{ deployment_name }}",
{%- if namespace is defined %}
namespace | doc "Kubernetes namespace" : String = "{{ namespace }}",
{%- else %}
namespace : String = "default",
{%- endif %}
},
# Deployment Specification
spec = {
replicas | doc "Number of replicas" : Number = {{ replicas | default(1) }},
# Pod Template Specification
template = {
# Pod Metadata
metadata = {
labels = {
app | doc "App label" : String = "{{ app_label }}",
{%- if version_label is defined %}
version | doc "Version label" : String = "{{ version_label }}",
{%- endif %}
},
},
# Pod Specification
spec = {
containers = [
{
name | doc "Container name" : String = "{{ container_name }}",
image | doc "Container image" : String = "{{ container_image }}",
{%- if image_pull_policy is defined %}
imagePullPolicy | doc "Image pull policy" : String = "{{ image_pull_policy }}",
{%- endif %}
# Port Configuration
{%- if container_port is defined %}
ports = [
{
containerPort | doc "Container port" : Number = {{ container_port }},
{%- if port_name is defined %}
name | doc "Port name" : String = "{{ port_name }}",
{%- endif %}
},
],
{%- endif %}
# Environment Variables
{%- if environment_vars is defined %}
env = [
{%- for var in environment_vars %}
{
name : String = "{{ var.name }}",
value : String = "{{ var.value }}",
},
{%- endfor %}
],
{%- endif %}
# Resource Limits
resources = {
{%- if cpu_request is defined %}
requests = {
cpu | doc "CPU request" : String = "{{ cpu_request }}",
{%- if memory_request is defined %}
memory | doc "Memory request" : String = "{{ memory_request }}",
{%- endif %}
},
{%- endif %}
{%- if cpu_limit is defined %}
limits = {
cpu | doc "CPU limit" : String = "{{ cpu_limit }}",
{%- if memory_limit is defined %}
memory | doc "Memory limit" : String = "{{ memory_limit }}",
{%- endif %}
},
{%- endif %}
},
# Health Checks
{%- if enable_liveness_probe == true or enable_liveness_probe == "true" %}
livenessProbe = {
httpGet = {
path | doc "Health check path" : String = "{{ liveness_path | default('/health') }}",
port | doc "Health check port" : Number = {{ liveness_port | default(8080) }},
},
initialDelaySeconds | doc "Initial delay" : Number = {{ liveness_delay | default(30) }},
periodSeconds | doc "Check period" : Number = {{ liveness_period | default(10) }},
},
{%- endif %}
{%- if enable_readiness_probe == true or enable_readiness_probe == "true" %}
readinessProbe = {
httpGet = {
path | doc "Readiness check path" : String = "{{ readiness_path | default('/ready') }}",
port | doc "Readiness check port" : Number = {{ readiness_port | default(8080) }},
},
initialDelaySeconds | doc "Initial delay" : Number = {{ readiness_delay | default(10) }},
periodSeconds | doc "Check period" : Number = {{ readiness_period | default(5) }},
},
{%- endif %}
},
],
# Pod Restart Policy
{%- if restart_policy is defined %}
restartPolicy | doc "Restart policy" : String = "{{ restart_policy }}",
{%- else %}
restartPolicy : String = "Always",
{%- endif %}
},
},
},
}

View File

@ -0,0 +1,54 @@
# Kubernetes Service Specification template
# Renders form data into a Nickel service definition
{
# Service Metadata
metadata = {
name | doc "Service name" : String = "{{ service_name }}",
{%- if service_namespace is defined %}
namespace | doc "Kubernetes namespace" : String = "{{ service_namespace }}",
{%- else %}
namespace : String = "default",
{%- endif %}
{%- if service_labels is defined %}
labels = {
app | doc "Application label" : String = "{{ service_labels }}",
},
{%- endif %}
},
# Service Specification
spec = {
type | doc "Service type (ClusterIP, NodePort, LoadBalancer)" : String = "{{ service_type | default('ClusterIP') }}",
# Selector for matching pods
selector = {
app | doc "App selector" : String = "{{ service_app | default(service_name) }}",
},
# Port configuration
ports = [
{
name | doc "Port name" : String = "{{ port_name | default('http') }}",
protocol | doc "Protocol (TCP, UDP)" : String = "TCP",
port | doc "Service port" : Number = {{ service_port | default(80) }},
targetPort | doc "Target container port" : Number = {{ target_port | default(8080) }},
{%- if node_port is defined %}
nodePort | doc "Node port (for NodePort service)" : Number = {{ node_port }},
{%- endif %}
},
],
{%- if session_affinity is defined %}
# Session Affinity
sessionAffinity | doc "Session affinity type" : String = "{{ session_affinity }}",
{%- endif %}
{%- if external_ips is defined %}
# External IPs
externalIPs = [
"{{ external_ips }}",
],
{%- endif %}
},
}

View File

@ -10,6 +10,7 @@ mod test "justfiles/test.just" # Test suite (unit, integration, docs)
mod dev "justfiles/dev.just" # Development tools (fmt, lint, watch, docs)
mod ci "justfiles/ci.just" # CI/CD pipeline (validate, test, build)
mod distro "justfiles/distro.just" # Distribution & packaging (release, cross-compile)
mod nickel "justfiles/nickel.just" # Nickel integration (schema → form generation)
# === SHARED VARIABLES ===
# WORKSPACE_ROOT := justfile_directory()

279
justfiles/nickel.just Normal file
View File

@ -0,0 +1,279 @@
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ Nickel Schema → TypeDialog Form Recipes ║
# ║ Generate TOML forms with fragments, conditionals, i18n ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Generate TOML form from Nickel schema with all features
[doc("Generate TOML form from Nickel schema")]
nickel-to-form SCHEMA OUTPUT_DIR="generated":
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ SCHEMA }}" ]; then
echo "Error: Schema file not found: {{ SCHEMA }}"
exit 1
fi
echo "Generating TOML form from {{ SCHEMA }}..."
mkdir -p "{{ OUTPUT_DIR }}/fragments"
mkdir -p "{{ OUTPUT_DIR }}/locales/en"
mkdir -p "{{ OUTPUT_DIR }}/locales/es"
# Generate main form with fragments, conditionals, i18n
cargo run --package typedialog -- \
nickel-to-form "{{ SCHEMA }}" \
--flatten \
--groups \
--fragments \
--conditionals \
--i18n \
--output "{{ OUTPUT_DIR }}"
echo "✓ Form generated in {{ OUTPUT_DIR }}/"
echo ""
echo "Generated files:"
[ -f "{{ OUTPUT_DIR }}/form.toml" ] && echo " - {{ OUTPUT_DIR }}/form.toml"
[ -f "{{ OUTPUT_DIR }}/main_form.toml" ] && echo " - {{ OUTPUT_DIR }}/main_form.toml"
[ -d "{{ OUTPUT_DIR }}/fragments" ] && [ "$(ls -A {{ OUTPUT_DIR }}/fragments 2>/dev/null)" ] && \
echo " - {{ OUTPUT_DIR }}/fragments/ ($(ls {{ OUTPUT_DIR }}/fragments/*.toml 2>/dev/null | wc -l) files)"
[ -d "{{ OUTPUT_DIR }}/locales" ] && [ "$(ls -A {{ OUTPUT_DIR }}/locales 2>/dev/null)" ] && \
echo " - {{ OUTPUT_DIR }}/locales/ ($(ls -d {{ OUTPUT_DIR }}/locales/* 2>/dev/null | wc -l) locales)"
# Complete workflow: schema → form → execute → Nickel output
[doc("Full workflow: schema → form → results → Nickel")]
nickel-workflow SCHEMA TEMPLATE OUTPUT="config.ncl":
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ SCHEMA }}" ]; then
echo "Error: Schema file not found: {{ SCHEMA }}"
exit 1
fi
if [ ! -f "{{ TEMPLATE }}" ]; then
echo "Error: Template file not found: {{ TEMPLATE }}"
exit 1
fi
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
TMP_FORM="$TMP_DIR/form.toml"
TMP_RESULTS="$TMP_DIR/results.json"
echo "=== Nickel Workflow ==="
echo ""
# Step 1: Generate form from schema
echo "1. Generating TOML form from schema..."
just nickel::nickel-to-form "{{ SCHEMA }}" "$TMP_DIR" > /dev/null
# Step 2: Execute form interactively
echo "2. Executing interactive form..."
[ -f "$TMP_DIR/main_form.toml" ] && FORM_FILE="$TMP_DIR/main_form.toml" || FORM_FILE="$TMP_DIR/form.toml"
cargo run --package typedialog -- \
form "$FORM_FILE" \
-o "$TMP_RESULTS" || {
echo "Error: Form execution failed"
exit 1
}
# Step 3: Render template
echo "3. Rendering Nickel output from template..."
cargo run --package typedialog -- \
nickel-template "{{ TEMPLATE }}" "$TMP_RESULTS" \
-o "{{ OUTPUT }}" || {
echo "Error: Template rendering failed"
exit 1
}
# Step 4: Validate output
echo "4. Validating Nickel configuration..."
nickel typecheck "{{ OUTPUT }}" || {
echo "Error: Generated Nickel is invalid"
exit 1
}
echo ""
echo "✓ Workflow complete!"
echo " - Nickel configuration written to {{ OUTPUT }}"
echo ""
echo "Next steps:"
echo " - Review: cat {{ OUTPUT }}"
echo " - Export: nickel export {{ OUTPUT }}"
# Preview form generation without writing files
[doc("Preview TOML form without writing")]
nickel-preview SCHEMA:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ SCHEMA }}" ]; then
echo "Error: Schema file not found: {{ SCHEMA }}"
exit 1
fi
echo "Previewing form generation for {{ SCHEMA }}..."
echo ""
cargo run --package typedialog -- \
nickel-to-form "{{ SCHEMA }}" \
--flatten \
--groups
# Extract i18n translations only
[doc("Extract i18n from Nickel schema")]
nickel-i18n SCHEMA OUTPUT_DIR="locales":
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ SCHEMA }}" ]; then
echo "Error: Schema file not found: {{ SCHEMA }}"
exit 1
fi
echo "Extracting i18n translations from {{ SCHEMA }}..."
mkdir -p "{{ OUTPUT_DIR }}"
cargo run --package typedialog -- \
nickel-to-form "{{ SCHEMA }}" \
--i18n \
--output "{{ OUTPUT_DIR }}"
echo "✓ Translations extracted to {{ OUTPUT_DIR }}/"
echo ""
if [ -d "{{ OUTPUT_DIR }}/locales" ]; then
echo "Generated .ftl files:"
find "{{ OUTPUT_DIR }}/locales" -name "*.ftl" -type f | sort | while read f; do
locale=$(basename $(dirname "$f"))
echo " - $locale: $f ($(wc -l < "$f") lines)"
done
fi
# Roundtrip workflow: .ncl → form → .ncl with preserved validators
[doc("Roundtrip: input.ncl → form → output.ncl (preserves validators)")]
nickel-roundtrip INPUT FORM OUTPUT="output.ncl" VALIDATE="true" VERBOSE="false":
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ INPUT }}" ]; then
echo "Error: Input Nickel file not found: {{ INPUT }}"
exit 1
fi
if [ ! -f "{{ FORM }}" ]; then
echo "Error: Form definition not found: {{ FORM }}"
exit 1
fi
VALIDATE_FLAG=""
if [ "{{ VALIDATE }}" != "true" ]; then
VALIDATE_FLAG="--no-validate"
fi
VERBOSE_FLAG=""
if [ "{{ VERBOSE }}" = "true" ]; then
VERBOSE_FLAG="-v"
fi
echo "=== Nickel Roundtrip Workflow ==="
echo "Input: {{ INPUT }}"
echo "Form: {{ FORM }}"
echo "Output: {{ OUTPUT }}"
echo ""
cargo run --package typedialog -- \
nickel-roundtrip \
"{{ INPUT }}" \
"{{ FORM }}" \
--output "{{ OUTPUT }}" \
$VALIDATE_FLAG \
$VERBOSE_FLAG
# Roundtrip with pre-executed form results (fast iteration)
[doc("Roundtrip from pre-computed form results")]
nickel-roundtrip-from-json INPUT RESULTS OUTPUT="output.ncl":
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ INPUT }}" ]; then
echo "Error: Input Nickel file not found: {{ INPUT }}"
exit 1
fi
if [ ! -f "{{ RESULTS }}" ]; then
echo "Error: Results JSON file not found: {{ RESULTS }}"
exit 1
fi
echo "Rendering Nickel from JSON results..."
echo "Input: {{ INPUT }}"
echo "Results: {{ RESULTS }}"
echo "Output: {{ OUTPUT }}"
echo ""
# Extract contracts from input and render with results
# (This is a placeholder - would need a dedicated command in CLI)
echo "Note: This requires implementation of nickel-render-json command"
exit 1
# Show nickel recipes help
[doc("Show Nickel integration recipes help")]
help:
@echo "╔═══════════════════════════════════════════════════════════╗"
@echo "║ Nickel Integration Recipes ║"
@echo "║ (For Nickel schema → TOML form generation) ║"
@echo "╚═══════════════════════════════════════════════════════════╝"
@echo ""
@echo "RECIPES:"
@echo ""
@echo " just nickel::nickel-to-form SCHEMA [OUTPUT_DIR]"
@echo " Generate TOML form from Nickel schema with fragments,"
@echo " conditionals, and i18n extraction."
@echo " Default OUTPUT_DIR: generated/"
@echo ""
@echo " just nickel::nickel-workflow SCHEMA TEMPLATE [OUTPUT]"
@echo " Complete workflow: schema → form → results → Nickel"
@echo " Validates output with 'nickel typecheck'."
@echo " Default OUTPUT: config.ncl"
@echo ""
@echo " just nickel::nickel-preview SCHEMA"
@echo " Preview form generation without writing files."
@echo " Useful for schema validation before full generation."
@echo ""
@echo " just nickel::nickel-i18n SCHEMA [OUTPUT_DIR]"
@echo " Extract and generate i18n (.ftl) files only."
@echo " Default OUTPUT_DIR: locales/"
@echo ""
@echo " just nickel::nickel-roundtrip INPUT FORM [OUTPUT] [VALIDATE] [VERBOSE]"
@echo " Roundtrip workflow with validator preservation:"
@echo " input.ncl → form execution → output.ncl"
@echo " Preserves validator imports and contract calls."
@echo " Default OUTPUT: output.ncl, VALIDATE: true, VERBOSE: false"
@echo ""
@echo "EXAMPLES:"
@echo ""
@echo " 1. Basic form generation:"
@echo " just nickel::nickel-to-form schema.ncl my_form/"
@echo ""
@echo " 2. Preview before generating:"
@echo " just nickel::nickel-preview examples/07-nickel-generation/schemas/simple.ncl"
@echo ""
@echo " 3. Extract i18n only:"
@echo " just nickel::nickel-i18n schema.ncl translations/"
@echo ""
@echo " 4. Full workflow with template:"
@echo " just nickel::nickel-workflow \\"
@echo " examples/07-nickel-generation/schemas/simple.ncl \\"
@echo " examples/07-nickel-generation/templates/config.ncl.j2 \\"
@echo " output_config.ncl"
@echo ""
@echo " 5. Roundtrip with validator preservation:"
@echo " just nickel::nickel-roundtrip \\"
@echo " provisioning/values/example.ncl \\"
@echo " provisioning/environment-form.toml \\"
@echo " -o output_config.ncl \\"
@echo " true true"
@echo ""

161
templates/nickel/README.md Normal file
View File

@ -0,0 +1,161 @@
# Nickel Template Library
Reutilizable template library for generating Nickel configuration schemas from TypeDialog forms.
## Structure
### Fields (`fields/`)
Basic field templates for different data types. Each template generates a single field definition.
- **string_field.ncl.j2** - String field with optional doc and contract
- **number_field.ncl.j2** - Number field with validation
- **boolean_field.ncl.j2** - Boolean flag field
- **optional_field.ncl.j2** - Optional field wrapper (any type)
- **enum_field.ncl.j2** - Enumerated field with predefined options
### Forms (`forms/`)
Complete schema templates for specific use cases. Each generates a full configuration structure.
- **config_schema.ncl.j2** - General application configuration
- **service_spec.ncl.j2** - Kubernetes service specification
- **deployment.ncl.j2** - Deployment configuration with environment and health checks
### Macros (`macros/`)
Reusable macro definitions for common patterns.
- **contracts.ncl.j2** - Validation contract definitions (NonEmpty, port ranges, etc.)
- **validation.ncl.j2** - Predicate macros (length, range, etc.)
- **metadata.ncl.j2** - Documentation and type annotation helpers
## Usage
### Field Templates
Use in form templates with Tera include syntax:
```jinja2
{% include "fields/string_field.ncl.j2" with {
name: "app_name",
doc: "Application name",
contract: "std.string.NonEmpty",
default: "myapp"
} %}
```
### Form Templates
Render a complete schema:
```bash
typedialog nickel-template forms/config_schema.ncl.j2 form_results.json -o config.ncl
```
### Macro Templates
Include macros in custom templates:
```jinja2
{% include "macros/contracts.ncl.j2" %}
{% include "macros/validation.ncl.j2" %}
```
Then use the defined macros:
```jinja2
{{ non_empty_string("username", "User login name") }}
{{ port_number("server_port", "HTTP port") }}
```
## Template Context Variables
Templates expect the following context from form results:
```json
{
"name": "field_name",
"doc": "field description",
"contract": "std.string.NonEmpty",
"default": "default_value",
"field_type": "String|Number|Bool",
"options": ["option1", "option2"],
"groups_by_section": { "section_name": [...fields...] }
}
```
## Examples
### Simple Configuration Schema
Create a form with fields for app name, version, and debug mode:
```toml
[[fields]]
name = "app_name"
type = "text"
prompt = "Application name"
required = true
[[fields]]
name = "app_version"
type = "text"
prompt = "Version"
default = "1.0.0"
[[fields]]
name = "debug_mode"
type = "confirm"
prompt = "Enable debug mode"
```
Render with template:
```bash
typedialog nickel-template forms/config_schema.ncl.j2 results.json -o config.ncl
```
Output:
```nickel
{
app = {
name | doc "Application name" : String = "myapp",
version | doc "Version" : String = "1.0.0",
debug_mode | doc "Enable debug mode" : Bool = true,
},
}
```
### Service Specification
Use for Kubernetes or containerized deployments:
```bash
typedialog form forms/service_spec.ncl.j2 \
-o service_results.json
typedialog nickel-template forms/service_spec.ncl.j2 \
service_results.json -o service.ncl
nickel export service.ncl
```
## Extending
Create new templates following these conventions:
1. **Single-field templates**`fields/{type}_field.ncl.j2`
2. **Schema templates**`forms/{purpose}_schema.ncl.j2`
3. **Macro libraries**`macros/{category}.ncl.j2`
All templates use Jinja2 syntax. Refer to Tera documentation for available filters and functions.
## Best Practices
- Use meaningful variable names in templates
- Document required context variables with comments
- Keep templates DRY by using macros for common patterns
- Validate generated Nickel with `nickel typecheck`
- Version templates with your forms for consistency

View File

@ -0,0 +1,12 @@
{%- if doc %}
{{ name }} | doc "{{ doc }}"
{%- else %}
{{ name }}
{%- endif %}
{%- if contract %}
| {{ contract }}
{%- endif %}
: Bool
{%- if default %}
= {{ default }}
{%- endif %},

View File

@ -0,0 +1,12 @@
{%- if doc %}
{{ name }} | doc "{{ doc }} Options: {% for opt in options %}{{ opt }}{{ ', ' if not loop.last }}{% endfor %}"
{%- else %}
{{ name }}
{%- endif %}
{%- if contract %}
| {{ contract }}
{%- endif %}
: String
{%- if default %}
= "{{ default }}"
{%- endif %},

View File

@ -0,0 +1,12 @@
{%- if doc %}
{{ name }} | doc "{{ doc }}"
{%- else %}
{{ name }}
{%- endif %}
{%- if contract %}
| {{ contract }}
{%- endif %}
: Number
{%- if default %}
= {{ default }}
{%- endif %},

View File

@ -0,0 +1,13 @@
{%- if doc %}
{{ name }} | doc "{{ doc }}"
{%- else %}
{{ name }}
{%- endif %}
{%- if contract %}
| {{ contract }}
{%- endif %}
| optional
: {{ field_type }}
{%- if default %}
= {{ default }}
{%- endif %},

View File

@ -0,0 +1,12 @@
{%- if doc %}
{{ name }} | doc "{{ doc }}"
{%- else %}
{{ name }}
{%- endif %}
{%- if contract %}
| {{ contract }}
{%- endif %}
: String
{%- if default %}
= "{{ default }}"
{%- endif %},

View File

@ -0,0 +1,12 @@
# Application configuration schema generated from TypeDialog form
{
{%- for group, fields in groups_by_section.items() %}
# {{ group | title }}
{{ group }} = {
{%- for field in fields %}
{% include "fields/string_field.ncl.j2" with field %},
{%- endfor %}
},
{%- endfor %}
}

View File

@ -0,0 +1,21 @@
# Deployment configuration generated from TypeDialog form
{
# Environment configuration
environment = {
{%- for key, value in env_vars.items() %}
{{ key }} = "{{ value }}",
{%- endfor %}
},
# Deployment settings
deployment = {
replicas | doc "Number of replicas" : Number = {{ replicas | default(3) }},
strategy | doc "Deployment strategy" : String = "{{ strategy | default('RollingUpdate') }}",
},
# Health checks
healthChecks = {
liveness | doc "Liveness probe enabled" : Bool = {{ liveness_enabled | default('true') }},
readiness | doc "Readiness probe enabled" : Bool = {{ readiness_enabled | default('true') }},
},
}

View File

@ -0,0 +1,21 @@
# Service specification generated from TypeDialog form
{
# Service metadata
service = {
name | doc "Service name" : String = "{{ service_name }}",
version | doc "Service version" : String = "{{ service_version }}",
description | doc "Service description" : String = "{{ service_description }}",
},
# Resources configuration
resources = {
cpu | doc "CPU allocation" : String = "{{ cpu | default('500m') }}",
memory | doc "Memory allocation" : String = "{{ memory | default('512Mi') }}",
},
# Network configuration
network = {
port | doc "Service port" : Number = {{ port | default(8080) }},
protocol | doc "Network protocol" : String = "{{ protocol | default('http') }}",
},
}

View File

@ -0,0 +1,21 @@
# Contract definition macros for common validation patterns
{%- macro non_empty_string(name, doc) %}
{{ name }} | doc "{{ doc }}" | std.string.NonEmpty : String
{%- endmacro %}
{%- macro email(name, doc) %}
{{ name }} | doc "{{ doc }}" : String
{%- endmacro %}
{%- macro url(name, doc) %}
{{ name }} | doc "{{ doc }}" : String
{%- endmacro %}
{%- macro port_number(name, doc) %}
{{ name }} | doc "{{ doc }}" | std.number.between 1 65535 : Number
{%- endmacro %}
{%- macro percentage(name, doc) %}
{{ name }} | doc "{{ doc }}" | std.number.between 0 100 : Number
{%- endmacro %}

View File

@ -0,0 +1,26 @@
# Metadata annotation macros for documentation and type information
{%- macro documented_field(name, doc, field_type, default) %}
{{ name }}
| doc "{{ doc }}"
: {{ field_type }}
{%- if default %}
= {{ default }}
{%- endif %}
{%- endmacro %}
{%- macro optional_documented(name, doc, field_type) %}
{{ name }}
| doc "{{ doc }}"
| optional
: {{ field_type }}
{%- endmacro %}
{%- macro required_field(name, field_type) %}
{{ name }} : {{ field_type }},
{%- endmacro %}
{%- macro deprecated(name, field_type, reason) %}
# DEPRECATED: {{ reason }}
{{ name }} : {{ field_type }},
{%- endmacro %}

View File

@ -0,0 +1,21 @@
# Validation predicate macros
{%- macro min_length(name, doc, min) %}
{{ name }} | doc "{{ doc }} (minimum {{ min }} characters)" | std.string.length.min {{ min }} : String
{%- endmacro %}
{%- macro max_length(name, doc, max) %}
{{ name }} | doc "{{ doc }} (maximum {{ max }} characters)" | std.string.length.max {{ max }} : String
{%- endmacro %}
{%- macro between_range(name, doc, min, max) %}
{{ name }} | doc "{{ doc }} (between {{ min }} and {{ max }})" | std.number.between {{ min }} {{ max }} : Number
{%- endmacro %}
{%- macro greater_than(name, doc, min) %}
{{ name }} | doc "{{ doc }} (greater than {{ min }})" | std.number.greater_than {{ min }} : Number
{%- endmacro %}
{%- macro less_than(name, doc, max) %}
{{ name }} | doc "{{ doc }} (less than {{ max }})" | std.number.less_than {{ max }} : Number
{%- endmacro %}