chore: add crates

This commit is contained in:
Jesús Pérez 2025-12-18 01:16:44 +00:00
parent 96b3ac1279
commit 5a459e7f02
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
36 changed files with 10398 additions and 0 deletions

74
Cargo.toml Normal file
View File

@ -0,0 +1,74 @@
[workspace]
members = [
"crates/typedialog-core",
"crates/typedialog",
"crates/typedialog-tui",
"crates/typedialog-web",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
authors = ["Jesús Pérez <jpl@jesusperez.com>"]
edition = "2021"
repository = "https://github.com/jesusperezlorenzo/typedialog"
license = "MIT"
[workspace.dependencies]
# Core serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
toml = "0.9"
# Utility
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "cargo"] }
async-trait = "0.1"
# Async
tokio = { version = "1", features = ["full"] }
futures = "0.3"
# Templates
tera = "1.20"
# i18n
fluent = "0.17"
fluent-bundle = "0.16"
unic-langid = "0.9"
sys-locale = "0.3"
dirs = "6.0"
# Nushell integration
nu-protocol = "0.109.1"
nu-plugin = "0.109.1"
# CLI Backend (inquire)
inquire = { version = "0.9", features = ["editor", "date"] }
dialoguer = "0.12"
rpassword = "7.4"
# TUI Backend (ratatui)
ratatui = "0.29"
crossterm = "0.29"
atty = "0.2"
# Web Backend (axum)
axum = "0.8.7"
tower = "0.5.2"
tower-http = { version = "0.6.8", features = ["fs", "cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"
# Misc
tempfile = "3.23"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = "warn"

View File

@ -0,0 +1,72 @@
[package]
name = "typedialog-core"
version.workspace = true
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
description = "Core library for TypeDialog - form handling and multiple rendering backends"
[lib]
name = "typedialog_core"
path = "src/lib.rs"
[dependencies]
# Core dependencies
serde = { workspace = true }
serde_json.workspace = true
serde_yaml.workspace = true
toml.workspace = true
chrono.workspace = true
anyhow.workspace = true
thiserror.workspace = true
async-trait.workspace = true
tera = { workspace = true, optional = true }
tempfile.workspace = true
# i18n (optional)
fluent = { workspace = true, optional = true }
fluent-bundle = { workspace = true, optional = true }
unic-langid = { workspace = true, optional = true }
sys-locale = { workspace = true, optional = true }
dirs = { workspace = true, optional = true }
# Nushell integration (optional)
nu-protocol = { workspace = true, optional = true }
nu-plugin = { workspace = true, optional = true }
# CLI Backend (inquire) - optional
inquire = { workspace = true, optional = true }
dialoguer = { workspace = true, optional = true }
rpassword = { workspace = true, optional = true }
# TUI Backend (ratatui) - optional
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true, optional = true }
atty = { workspace = true, optional = true }
# Web Backend (axum) - optional
axum = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
tower = { workspace = true, optional = true }
tower-http = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
tracing-subscriber = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
[dev-dependencies]
serde_json.workspace = true
[features]
default = ["cli", "i18n", "templates"]
cli = ["inquire", "dialoguer", "rpassword"]
tui = ["ratatui", "crossterm", "atty"]
web = ["axum", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", "futures"]
i18n = ["fluent", "fluent-bundle", "unic-langid", "sys-locale", "dirs"]
templates = ["tera"]
nushell = ["nu-protocol", "nu-plugin"]
all-backends = ["cli", "tui", "web"]
full = ["i18n", "templates", "nushell"]
[lints]
workspace = true

View File

@ -0,0 +1,273 @@
//! Autocompletion utilities for interactive prompts
//!
//! Provides helper classes for:
//! 1. **HistoryCompleter** - Remembers and suggests previously entered values
//! 2. **FilterCompleter** - Dynamically filters options
//! 3. **PatternCompleter** - Provides intelligent suggestions based on patterns
use std::collections::VecDeque;
/// History-based completion helper
/// Remembers previously entered values and suggests matches
#[derive(Clone, Debug)]
pub struct HistoryCompleter {
history: VecDeque<String>,
max_size: usize,
}
impl HistoryCompleter {
/// Create a new history completer
pub fn new(max_size: usize) -> Self {
Self {
history: VecDeque::new(),
max_size,
}
}
/// Add a value to history
pub fn add(&mut self, value: String) {
if !value.is_empty() {
self.history.push_front(value);
if self.history.len() > self.max_size {
self.history.pop_back();
}
}
}
/// Get matching suggestions from history
pub fn suggest(&self, partial: &str) -> Vec<String> {
if partial.is_empty() {
return self.history.iter().cloned().collect();
}
self.history
.iter()
.filter(|item| item.to_lowercase().contains(&partial.to_lowercase()))
.cloned()
.collect()
}
/// Get first completion suggestion
pub fn complete(&self, partial: &str) -> Option<String> {
if partial.is_empty() {
return self.history.front().cloned();
}
self.history
.iter()
.find(|item| item.starts_with(partial))
.cloned()
}
/// Get full history
pub fn get_history(&self) -> Vec<String> {
self.history.iter().cloned().collect()
}
/// Clear history
pub fn clear(&mut self) {
self.history.clear();
}
}
impl Default for HistoryCompleter {
fn default() -> Self {
Self::new(10)
}
}
/// Filter-based completion helper
/// Dynamically filters available options
#[derive(Clone, Debug)]
pub struct FilterCompleter {
options: Vec<String>,
}
impl FilterCompleter {
/// Create a new filter completer
pub fn new(options: Vec<String>) -> Self {
Self { options }
}
/// Get filtered options matching the partial input
pub fn filter(&self, partial: &str) -> Vec<String> {
if partial.is_empty() {
return self.options.clone();
}
self.options
.iter()
.filter(|opt| opt.to_lowercase().contains(&partial.to_lowercase()))
.cloned()
.collect()
}
/// Get first matching option
pub fn complete(&self, partial: &str) -> Option<String> {
if partial.is_empty() {
return self.options.first().cloned();
}
self.options
.iter()
.find(|opt| opt.to_lowercase().starts_with(&partial.to_lowercase()))
.cloned()
}
}
/// Pattern types for intelligent completion
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
pub enum PatternType {
/// Email pattern (suggests @domain)
Email,
/// URL pattern (suggests http://, https://)
Url,
/// File path pattern (suggests /, ./, ../)
FilePath,
/// IPv4 address pattern (suggests octets)
IPv4,
/// Port number pattern (suggests :)
Port,
}
/// Pattern-based completion helper
/// Provides intelligent suggestions based on input patterns
#[derive(Clone, Debug)]
pub struct PatternCompleter {
pattern: PatternType,
suggestions: Vec<String>,
}
impl PatternCompleter {
/// Create a new pattern completer
pub fn new(pattern: PatternType) -> Self {
let suggestions = match pattern {
PatternType::Email => vec![
"@gmail.com".to_string(),
"@outlook.com".to_string(),
"@example.com".to_string(),
"@company.com".to_string(),
],
PatternType::Url => vec![
"https://".to_string(),
"http://".to_string(),
"ftp://".to_string(),
"ssh://".to_string(),
],
PatternType::FilePath => vec![
"./".to_string(),
"../".to_string(),
"/".to_string(),
"~/".to_string(),
],
PatternType::IPv4 => vec![
"192.168.".to_string(),
"10.0.".to_string(),
"172.16.".to_string(),
"127.0.".to_string(),
],
PatternType::Port => vec![
":8080".to_string(),
":3000".to_string(),
":5432".to_string(),
":80".to_string(),
":443".to_string(),
],
};
Self { pattern, suggestions }
}
/// Add custom suggestion
pub fn add_suggestion(&mut self, suggestion: String) {
if !self.suggestions.contains(&suggestion) {
self.suggestions.push(suggestion);
}
}
/// Get suggestions matching the pattern
pub fn suggest(&self, partial: &str) -> Vec<String> {
self.suggestions
.iter()
.filter(|s| s.starts_with(partial) || partial.is_empty())
.cloned()
.collect()
}
/// Get first matching suggestion
pub fn complete(&self, partial: &str) -> Option<String> {
self.suggestions
.iter()
.find(|s| s.starts_with(partial))
.cloned()
.or_else(|| {
// Smart completion based on pattern type
match self.pattern {
PatternType::Email if !partial.contains('@') && !partial.is_empty() => {
Some("@example.com".to_string())
}
PatternType::Url if !partial.starts_with("http") && partial.is_empty() => {
Some("https://".to_string())
}
PatternType::Port if !partial.contains(':') && !partial.is_empty() => {
Some(":8080".to_string())
}
_ => None,
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_history_completer() {
let mut completer = HistoryCompleter::new(3);
completer.add("apple".to_string());
completer.add("application".to_string());
assert_eq!(completer.get_history().len(), 2);
assert_eq!(completer.get_history()[0], "application");
}
#[test]
fn test_history_max_size() {
let mut completer = HistoryCompleter::new(2);
completer.add("first".to_string());
completer.add("second".to_string());
completer.add("third".to_string());
assert_eq!(completer.get_history().len(), 2);
}
#[test]
fn test_filter_completer() {
let options = vec!["apple".to_string(), "application".to_string(), "banana".to_string()];
let completer = FilterCompleter::new(options);
let filtered = completer.filter("app");
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().any(|s| s == "apple"));
}
#[test]
fn test_pattern_completer_email() {
let mut completer = PatternCompleter::new(PatternType::Email);
completer.add_suggestion("@mydomain.com".to_string());
let suggestions = completer.suggest("@");
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.contains("mydomain")));
}
#[test]
fn test_pattern_completer_smart() {
let completer = PatternCompleter::new(PatternType::Email);
// Smart completion adds @example.com when there's no @
let completion = completer.complete("user");
assert_eq!(completion, Some("@example.com".to_string()));
}
}

View File

@ -0,0 +1,201 @@
//! CLI Backend using inquire for interactive prompts
//!
//! This backend provides the existing inquire-based CLI interface.
//! It will be the primary implementation of FormBackend for terminal-based forms.
use async_trait::async_trait;
use serde_json::Value;
use crate::error::Result;
use crate::form_parser::{FieldDefinition, DisplayItem, FieldType};
use crate::prompts;
use super::{FormBackend, RenderContext};
/// CLI Backend implementation using inquire
pub struct InquireBackend;
impl InquireBackend {
/// Create a new CLI backend instance
pub fn new() -> Self {
InquireBackend
}
#[allow(clippy::only_used_in_recursion)]
fn execute_field_sync(&self, field: &FieldDefinition) -> Result<Value> {
let is_required = field.required.unwrap_or(false);
let required_marker = if is_required { " *" } else { " (optional)" };
match field.field_type {
FieldType::Text => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let result = prompts::text(&prompt_with_marker, field.default.as_deref(), field.placeholder.as_deref())?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
}
Ok(serde_json::json!(result))
}
FieldType::Confirm => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let default_bool = field.default.as_deref().and_then(|s| match s.to_lowercase().as_str() {
"true" | "yes" => Some(true),
"false" | "no" => Some(false),
_ => None,
});
let result = prompts::confirm(&prompt_with_marker, default_bool, None)?;
Ok(serde_json::json!(result))
}
FieldType::Password => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let with_toggle = field.placeholder.as_deref() == Some("toggle");
let result = prompts::password(&prompt_with_marker, with_toggle)?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
}
Ok(serde_json::json!(result))
}
FieldType::Select => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let options = field.options.clone().ok_or_else(|| {
crate::Error::form_parse_failed("Select field requires 'options'")
})?;
let result = prompts::select(
&prompt_with_marker,
options,
field.page_size,
field.vim_mode.unwrap_or(false),
)?;
Ok(serde_json::json!(result))
}
FieldType::MultiSelect => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let options = field.options.clone().ok_or_else(|| {
crate::Error::form_parse_failed("MultiSelect field requires 'options'")
})?;
let results = prompts::multi_select(
&prompt_with_marker,
options,
field.page_size,
field.vim_mode.unwrap_or(false),
)?;
if is_required && results.is_empty() {
eprintln!("⚠ This field is required. Please select at least one option.");
return self.execute_field_sync(field);
}
Ok(serde_json::json!(results))
}
FieldType::Editor => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let result = prompts::editor(&prompt_with_marker, field.file_extension.as_deref(), field.prefix_text.as_deref())?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
}
Ok(serde_json::json!(result))
}
FieldType::Date => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let week_start = field.week_start.as_deref().unwrap_or("Mon");
let result = prompts::date(
&prompt_with_marker,
field.default.as_deref(),
field.min_date.as_deref(),
field.max_date.as_deref(),
week_start,
)?;
Ok(serde_json::json!(result))
}
FieldType::Custom => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let type_name = field.custom_type.as_ref().ok_or_else(|| {
crate::Error::form_parse_failed("Custom field requires 'custom_type'")
})?;
let result = prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
}
Ok(serde_json::json!(result))
}
}
}
}
impl Default for InquireBackend {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl FormBackend for InquireBackend {
async fn initialize(&mut self) -> Result<()> {
// No initialization needed for CLI backend
Ok(())
}
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> {
item.render(&context.results);
Ok(())
}
async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result<Value> {
// Wrap synchronous field execution in async context
self.execute_field_sync(field)
}
async fn shutdown(&mut self) -> Result<()> {
// No cleanup needed for CLI backend
Ok(())
}
fn is_available() -> bool {
// CLI backend is always available
true
}
fn name(&self) -> &str {
"cli"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inquire_backend_new() {
let backend = InquireBackend::new();
assert_eq!(backend.name(), "cli");
}
#[test]
fn test_inquire_backend_available() {
assert!(InquireBackend::is_available());
}
#[test]
fn test_inquire_backend_default() {
let backend = InquireBackend::default();
assert_eq!(backend.name(), "cli");
}
#[tokio::test]
async fn test_inquire_backend_lifecycle() {
let mut backend = InquireBackend::new();
assert!(backend.initialize().await.is_ok());
assert!(backend.shutdown().await.is_ok());
}
}

View File

@ -0,0 +1,184 @@
//! Form rendering backends abstraction
//!
//! This module provides a trait-based abstraction for different form rendering
//! backends (CLI with inquire, TUI with ratatui, Web with axum, etc.).
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use crate::error::Result;
use crate::form_parser::{FieldDefinition, DisplayItem};
/// Context passed to rendering operations
#[derive(Debug, Clone)]
pub struct RenderContext {
/// Previous field results (for conditional rendering)
pub results: HashMap<String, Value>,
/// Optional locale override
pub locale: Option<String>,
}
/// Trait for form rendering backends
///
/// Implementations handle the rendering of display items and execution of form fields.
/// Different backends can target different environments (CLI, TUI, Web, etc.).
#[async_trait]
pub trait FormBackend: Send + Sync {
/// Initialize the backend (setup terminal, start server, etc.)
async fn initialize(&mut self) -> Result<()>;
/// Render a display item (header, section, border, footer, etc.)
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>;
/// Execute a field and return user input (field-by-field mode)
async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value>;
/// Execute complete form with all fields at once (complete mode)
/// Returns all field values as a map
async fn execute_form_complete(
&mut self,
form: &crate::form_parser::FormDefinition,
items: &[DisplayItem],
fields: &[FieldDefinition],
) -> Result<std::collections::HashMap<String, Value>> {
// Default implementation: fall back to field-by-field mode
let mut results = std::collections::HashMap::new();
let mut context = RenderContext {
results: results.clone(),
locale: form.locale.clone(),
};
for item in items {
self.render_display_item(item, &context).await?;
}
for field in fields {
context.results = results.clone();
let value = self.execute_field(field, &context).await?;
results.insert(field.name.clone(), value);
}
Ok(results)
}
/// Cleanup/shutdown the backend
async fn shutdown(&mut self) -> Result<()>;
/// Check if this backend is available on the current system
fn is_available() -> bool
where
Self: Sized;
/// Get the backend name (for logging/debugging)
fn name(&self) -> &str;
}
/// Backend type enumeration for factory pattern
#[derive(Debug, Clone, Copy)]
pub enum BackendType {
/// CLI backend using inquire
Cli,
/// TUI backend using ratatui
#[cfg(feature = "tui")]
Tui,
/// Web backend using axum
#[cfg(feature = "web")]
Web { port: u16 },
}
/// Factory for creating backend instances
pub struct BackendFactory;
impl BackendFactory {
/// Create a backend instance based on the provided type
pub fn create(backend_type: BackendType) -> Result<Box<dyn FormBackend>> {
match backend_type {
BackendType::Cli => {
#[cfg(feature = "cli")]
{
Ok(Box::new(cli::InquireBackend::new()))
}
#[cfg(not(feature = "cli"))]
{
Err(crate::error::Error::new(
crate::error::ErrorKind::Other,
"CLI backend not enabled. Compile with --features cli",
))
}
}
#[cfg(feature = "tui")]
BackendType::Tui => {
Ok(Box::new(tui::RatatuiBackend::new()))
}
#[cfg(feature = "web")]
BackendType::Web { port } => {
Ok(Box::new(web::WebBackend::new(port)))
}
}
}
/// Auto-detect the best available backend
pub fn auto_detect() -> BackendType {
// Check environment variable override
if let Ok(backend) = std::env::var("TYPEDIALOG_BACKEND") {
match backend.to_lowercase().as_str() {
"tui" => {
#[cfg(feature = "tui")]
return BackendType::Tui;
#[cfg(not(feature = "tui"))]
{}
}
"web" => {
#[cfg(feature = "web")]
{
let port = std::env::var("TYPEDIALOG_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(9000);
return BackendType::Web { port };
}
#[cfg(not(feature = "web"))]
{}
}
_ => {}
}
}
// Default to CLI backend
BackendType::Cli
}
}
/// CLI Backend - using inquire for interactive prompts
#[cfg(feature = "cli")]
pub mod cli;
/// TUI Backend - using ratatui for terminal UI
#[cfg(feature = "tui")]
pub mod tui;
/// Web Backend - using axum for web forms
#[cfg(feature = "web")]
pub mod web;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_type_debug() {
let backend = BackendType::Cli;
let debug_str = format!("{:?}", backend);
assert!(debug_str.contains("Cli"));
}
#[test]
fn test_render_context_clone() {
let ctx = RenderContext {
results: HashMap::new(),
locale: Some("en-US".to_string()),
};
let cloned = ctx.clone();
assert_eq!(cloned.locale, ctx.locale);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
//! Common CLI patterns and help text for all typedialog binaries
/// Long help for typedialog (CLI) main command
pub const CLI_MAIN_LONG_ABOUT: &str = r#"typedialog - Create interactive forms and prompts with multiple backends.
By default uses CLI backend (step-by-step prompts). Switch backends using TYPEDIALOG_BACKEND
environment variable or use specialized binaries:
- typedialog: CLI backend (this tool)
- typedialog-tui: Terminal UI backend
- typedialog-web: Web server backend
All backends produce identical JSON output from the same TOML form.
BACKENDS:
CLI Step-by-step prompts (scriptable, default)
Run: typedialog form <config>
TUI 3-panel terminal UI (best for interactive use)
Run: typedialog-tui <config>
Or: TYPEDIALOG_BACKEND=tui typedialog form <config>
Web Browser-based interface (best for teams)
Run: typedialog-web <config> --port 8080
Or: TYPEDIALOG_BACKEND=web typedialog form <config>
ENVIRONMENT VARIABLES:
TYPEDIALOG_BACKEND Backend to use: 'cli', 'tui', or 'web' (default: 'cli')
TYPEDIALOG_PORT Port for web backend (default: 9000)
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
Priority: CLI flag > form locale > TYPEDIALOG_LANG > LANG > LC_ALL > default
LANG System locale (fallback if TYPEDIALOG_LANG not set)
LC_ALL Alternate locale setting (fallback to LANG)
EXAMPLES:
# Use TUI backend instead of CLI
TYPEDIALOG_BACKEND=tui typedialog form config.toml
# Use Web backend on custom port
TYPEDIALOG_BACKEND=web TYPEDIALOG_PORT=8080 typedialog form config.toml
# Force Spanish locale
TYPEDIALOG_LANG=es-ES typedialog form config.toml"#;
/// Long help for typedialog-tui
pub const TUI_MAIN_LONG_ABOUT: &str = r#"typedialog-tui - Terminal UI tool for interactive forms.
Provides an enhanced 3-panel terminal interface (left: field list, right: input editor, bottom: buttons)
with conditional field visibility and smart defaults.
This is the TUI-specific binary. To use TUI backend with typedialog CLI, set:
TYPEDIALOG_BACKEND=tui typedialog form <config>
Available backends via typedialog main command:
- typedialog: CLI backend (step-by-step prompts)
- typedialog-tui: TUI backend (this tool, recommended for interactive use)
- typedialog-web: Web backend (browser-based)
ENVIRONMENT VARIABLES:
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
LANG System locale (fallback if TYPEDIALOG_LANG not set)
LC_ALL Alternate locale setting (fallback to LANG)
EXAMPLES:
# Run TUI form directly
typedialog-tui config.toml
# With Spanish locale
TYPEDIALOG_LANG=es-ES typedialog-tui config.toml
# Output as JSON
typedialog-tui config.toml --format json > results.json"#;
/// Long help for typedialog-web
pub const WEB_MAIN_LONG_ABOUT: &str = r#"typedialog-web - Web server for interactive forms.
Provides an HTTP server for filling out forms via web browser. Access forms at:
http://localhost:PORT/form/CONFIG_NAME
This is the Web-specific binary. To use Web backend with typedialog CLI, set:
TYPEDIALOG_BACKEND=web typedialog form <config>
Available backends via typedialog main command:
- typedialog: CLI backend (step-by-step prompts)
- typedialog-tui: TUI backend (terminal UI)
- typedialog-web: Web backend (this tool, recommended for teams)
ENVIRONMENT VARIABLES:
TYPEDIALOG_PORT Port to listen on (default: 9000, overridden by --port flag)
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
LANG System locale (fallback if TYPEDIALOG_LANG not set)
LC_ALL Alternate locale setting (fallback to LANG)
EXAMPLES:
# Run web server on default port 9000
typedialog-web config.toml
# Run on custom port via environment variable
TYPEDIALOG_PORT=8080 typedialog-web config.toml
# Run on custom port via flag (takes precedence)
typedialog-web config.toml --port 3000
# With Spanish locale
TYPEDIALOG_LANG=es-ES typedialog-web config.toml --port 8080"#;
/// Help text explaining environment variables
pub const ENV_VARS_HELP: &str = r#"
ENVIRONMENT VARIABLES:
TYPEDIALOG_BACKEND Backend to use: 'cli', 'tui', or 'web' (default: 'cli')
Only used by 'typedialog' command
TYPEDIALOG_PORT Port for web backend (default: 9000)
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
LANG System locale (fallback if TYPEDIALOG_LANG not set)
LC_ALL Alternate locale setting (fallback to LANG)
EXAMPLES:
# Use TUI backend instead of CLI
TYPEDIALOG_BACKEND=tui typedialog form config.toml
# Use Web backend on custom port
TYPEDIALOG_BACKEND=web TYPEDIALOG_PORT=8080 typedialog form config.toml
# Force Spanish locale
TYPEDIALOG_LANG=es-ES typedialog form config.toml
"#;
/// Help text explaining backends
pub const BACKENDS_HELP: &str = r#"
BACKENDS:
CLI Step-by-step prompts (scriptable, default)
Run: typedialog form <config>
TUI 3-panel terminal UI (best for interactive use)
Run: typedialog-tui <config>
Or: TYPEDIALOG_BACKEND=tui typedialog form <config>
Web Browser-based interface (best for teams)
Run: typedialog-web <config> --port 8080
Or: TYPEDIALOG_BACKEND=web typedialog form <config>
All backends produce identical JSON output from the same TOML form definition.
"#;
/// Help text for --locale flag
pub const LOCALE_FLAG_HELP: &str =
"Locale override for form localization (e.g., 'en-US', 'es-ES')\n\
Priority: CLI flag > form locale > TYPEDIALOG_LANG > LANG > LC_ALL > default (en-US)";
/// Help text for --format flag
pub const FORMAT_FLAG_HELP: &str =
"Output format: 'json', 'yaml', 'toml', or 'text' (default: text)\n\
JSON format enables integration with other tools (jq, terraform, etc.)";
/// Help text for --out flag
pub const OUT_FLAG_HELP: &str = "Output file (if not specified, writes to stdout)";

View File

@ -0,0 +1,46 @@
//! Configuration file loader
use crate::config::TypeDialogConfig;
use crate::error::{Error, Result};
use std::fs;
use std::path::PathBuf;
/// Load global configuration from ~/.config/typedialog/config.toml
///
/// If the file doesn't exist, returns the default configuration.
pub fn load_global_config() -> Result<TypeDialogConfig> {
let config_path = get_config_path()?;
if config_path.exists() {
let content = fs::read_to_string(&config_path)?;
toml::from_str(&content).map_err(|e| {
Error::config_not_found(format!(
"Failed to parse config file at {:?}: {}",
config_path, e
))
})
} else {
Ok(TypeDialogConfig::default())
}
}
/// Get the config file path (~/.config/typedialog/config.toml)
fn get_config_path() -> Result<PathBuf> {
#[cfg(feature = "i18n")]
{
use dirs::config_dir;
let config_dir = config_dir().ok_or_else(|| {
Error::config_not_found("Unable to determine config directory")
})?;
Ok(config_dir.join("typedialog").join("config.toml"))
}
#[cfg(not(feature = "i18n"))]
{
// Fallback without dirs dependency
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| Error::config_not_found("Unable to determine home directory"))
.map(|home| PathBuf::from(home).join(".config/typedialog/config.toml"))
}
}

View File

@ -0,0 +1,68 @@
//! Configuration management for typedialog
//!
//! Handles global configuration loading and defaults.
mod loader;
pub use loader::load_global_config;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Global configuration for typedialog
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypeDialogConfig {
/// Default locale (e.g., "en-US", "es-ES")
pub locale: Option<String>,
/// Path to locales directory
pub locales_path: PathBuf,
/// Path to templates directory
pub templates_path: PathBuf,
/// Fallback locale when the requested locale is not available
pub fallback_locale: String,
}
impl Default for TypeDialogConfig {
fn default() -> Self {
Self {
locale: None,
locales_path: PathBuf::from("./locales"),
templates_path: PathBuf::from("./templates"),
fallback_locale: "en-US".to_string(),
}
}
}
impl TypeDialogConfig {
/// Create a new config with default values
pub fn new() -> Self {
Self::default()
}
/// Set the default locale
pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
self.locale = Some(locale.into());
self
}
/// Set the locales directory path
pub fn with_locales_path(mut self, path: PathBuf) -> Self {
self.locales_path = path;
self
}
/// Set the templates directory path
pub fn with_templates_path(mut self, path: PathBuf) -> Self {
self.templates_path = path;
self
}
/// Set the fallback locale
pub fn with_fallback_locale(mut self, locale: impl Into<String>) -> Self {
self.fallback_locale = locale.into();
self
}
}

View File

@ -0,0 +1,182 @@
//! Error handling for typedialog
//!
//! Provides structured error types for all operations.
use std::fmt;
use std::io;
/// Errors that can occur during form operations
#[derive(Debug)]
pub struct Error {
kind: ErrorKind,
message: String,
}
/// Error kinds for form operations
#[derive(Debug)]
pub enum ErrorKind {
/// User cancelled the prompt
Cancelled,
/// Form parsing failed
FormParseFailed,
/// I/O error
Io,
/// TOML parsing error
TomlParse,
/// Validation failed
ValidationFailed,
/// i18n (internationalization) error
I18nFailed,
/// Template error
TemplateFailed,
/// Configuration error
ConfigNotFound,
/// Other errors
Other,
}
impl Error {
/// Create a new error with a specific kind and message
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// Create a cancelled error
pub fn cancelled() -> Self {
Self::new(ErrorKind::Cancelled, "Operation cancelled")
}
/// Create a form parse error
pub fn form_parse_failed(msg: impl Into<String>) -> Self {
Self::new(ErrorKind::FormParseFailed, msg)
}
/// Create an I/O error
pub fn io(source: io::Error) -> Self {
Self::new(ErrorKind::Io, format!("I/O error: {}", source))
}
/// Create a TOML parse error
pub fn toml_parse(source: toml::de::Error) -> Self {
Self::new(ErrorKind::TomlParse, format!("TOML parse error: {}", source))
}
/// Create a validation error
pub fn validation_failed(msg: impl Into<String>) -> Self {
Self::new(ErrorKind::ValidationFailed, msg)
}
/// Create an i18n error
pub fn i18n_failed(msg: impl Into<String>) -> Self {
Self::new(ErrorKind::I18nFailed, msg)
}
/// Create a template error
pub fn template_failed(msg: impl Into<String>) -> Self {
Self::new(ErrorKind::TemplateFailed, msg)
}
/// Create a config error
pub fn config_not_found(msg: impl Into<String>) -> Self {
Self::new(ErrorKind::ConfigNotFound, msg)
}
/// Get the error kind
pub fn kind(&self) -> &ErrorKind {
&self.kind
}
/// Get the error message
pub fn message(&self) -> &str {
&self.message
}
/// Check if this is a cancellation error
pub fn is_cancelled(&self) -> bool {
matches!(self.kind, ErrorKind::Cancelled)
}
/// Check if this is a parse error
pub fn is_parse_error(&self) -> bool {
matches!(
self.kind,
ErrorKind::FormParseFailed | ErrorKind::TomlParse
)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for Error {}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Self {
Self::io(err)
}
}
impl From<toml::de::Error> for Error {
fn from(err: toml::de::Error) -> Self {
Self::toml_parse(err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::new(ErrorKind::Other, format!("JSON error: {}", err))
}
}
impl From<serde_yaml::Error> for Error {
fn from(err: serde_yaml::Error) -> Self {
Self::new(ErrorKind::Other, format!("YAML error: {}", err))
}
}
impl From<chrono::ParseError> for Error {
fn from(err: chrono::ParseError) -> Self {
Self::new(ErrorKind::ValidationFailed, format!("Date parsing error: {}", err))
}
}
impl From<inquire::InquireError> for Error {
fn from(err: inquire::InquireError) -> Self {
match err {
inquire::InquireError::OperationCanceled => Self::cancelled(),
_ => Self::new(ErrorKind::Other, format!("Prompt error: {}", err)),
}
}
}
/// Result type for typedialog operations
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let err = Error::validation_failed("test error");
assert_eq!(err.to_string(), "test error");
}
#[test]
fn test_error_kind() {
let err = Error::cancelled();
assert!(err.is_cancelled());
}
#[test]
fn test_parse_error() {
let err = Error::form_parse_failed("parse failed");
assert!(err.is_parse_error());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,128 @@
//! Helper utilities for value conversions
//!
//! Provides conversion functions between JSON values and other formats
//! for serialization and display purposes.
use serde_json::{json, Value};
use std::collections::HashMap;
/// Convert a HashMap of JSON values to a formatted string
///
/// # Arguments
///
/// * `results` - HashMap of field names to JSON values
/// * `format` - Output format: "json", "yaml", or "text"
///
/// # Returns
///
/// Formatted string representation of the results
pub fn format_results(
results: &HashMap<String, Value>,
format: &str,
) -> crate::error::Result<String> {
match format {
"json" => {
let json_obj = serde_json::to_value(results)
.map_err(|e| crate::Error::new(
crate::error::ErrorKind::Other,
format!("JSON serialization error: {}", e),
))?;
Ok(serde_json::to_string_pretty(&json_obj)?)
}
"yaml" => {
let yaml_string = serde_yaml::to_string(results)
.map_err(|e| crate::Error::new(
crate::error::ErrorKind::Other,
format!("YAML serialization error: {}", e),
))?;
Ok(yaml_string)
}
"text" => {
let mut output = String::new();
for (key, value) in results {
output.push_str(&format!("{}: {}\n", key, format_value(value)));
}
Ok(output)
}
_ => Err(crate::Error::new(
crate::error::ErrorKind::ValidationFailed,
format!("Unknown output format: {}", format),
)),
}
}
/// Format a JSON value for display
fn format_value(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(format_value).collect();
format!("[{}]", items.join(", "))
}
Value::Object(obj) => {
let items: Vec<String> = obj
.iter()
.map(|(k, v)| format!("{}: {}", k, format_value(v)))
.collect();
format!("{{{}}}", items.join(", "))
}
}
}
/// Convert results to JSON value
pub fn to_json_value(results: &HashMap<String, Value>) -> Value {
json!(results)
}
/// Convert results to JSON string
pub fn to_json_string(results: &HashMap<String, Value>) -> crate::error::Result<String> {
serde_json::to_string(&to_json_value(results))
.map_err(|e| crate::Error::new(
crate::error::ErrorKind::Other,
format!("JSON error: {}", e),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_results_json() {
let mut results = HashMap::new();
results.insert("name".to_string(), json!("Alice"));
results.insert("age".to_string(), json!(30));
let formatted = format_results(&results, "json").unwrap();
assert!(formatted.contains("name"));
assert!(formatted.contains("Alice"));
}
#[test]
fn test_format_results_text() {
let mut results = HashMap::new();
results.insert("name".to_string(), json!("Bob"));
results.insert("role".to_string(), json!("Admin"));
let formatted = format_results(&results, "text").unwrap();
assert!(formatted.contains("name: Bob"));
assert!(formatted.contains("role: Admin"));
}
#[test]
fn test_format_value_array() {
let arr = json!(["a", "b", "c"]);
assert_eq!(format_value(&arr), "[a, b, c]");
}
#[test]
fn test_format_value_object() {
let obj = json!({"x": 1, "y": 2});
let formatted = format_value(&obj);
assert!(formatted.contains("x: 1"));
assert!(formatted.contains("y: 2"));
}
}

View File

@ -0,0 +1,158 @@
//! Locale file loader for Fluent and TOML translations
use crate::error::{Error, Result};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use unic_langid::LanguageIdentifier;
/// Loader for locale files (both .ftl and .toml)
pub struct LocaleLoader {
locales_path: PathBuf,
}
impl LocaleLoader {
/// Create a new locale loader with the given locales directory path
pub fn new(locales_path: PathBuf) -> Self {
Self { locales_path }
}
/// Load Fluent (.ftl) resources for a specific locale
///
/// Searches for files matching `locales/{locale}/*.ftl`
pub fn load_fluent(&self, locale: &LanguageIdentifier) -> Result<Vec<String>> {
let locale_dir = self.locales_path.join(locale.to_string());
if !locale_dir.exists() {
return Err(Error::i18n_failed(format!(
"Locale directory not found: {:?}",
locale_dir
)));
}
let mut resources = Vec::new();
match fs::read_dir(&locale_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("ftl") {
match fs::read_to_string(&path) {
Ok(content) => resources.push(content),
Err(e) => {
return Err(Error::i18n_failed(format!(
"Failed to read {:?}: {}",
path, e
)))
}
}
}
}
}
Err(e) => {
return Err(Error::i18n_failed(format!(
"Failed to read locale directory {:?}: {}",
locale_dir, e
)))
}
}
Ok(resources)
}
/// Load TOML-based translations for a specific locale
///
/// Searches for file `locales/{locale}.toml` and flattens nested structure
pub fn load_toml(&self, locale: &LanguageIdentifier) -> Result<HashMap<String, String>> {
let toml_file = self.locales_path.join(format!("{}.toml", locale));
if !toml_file.exists() {
return Ok(HashMap::new());
}
match fs::read_to_string(&toml_file) {
Ok(content) => {
match toml::from_str::<toml::map::Map<String, toml::Value>>(&content) {
Ok(root) => Ok(Self::flatten_toml(root, "")),
Err(e) => Err(Error::i18n_failed(format!(
"Failed to parse TOML file {:?}: {}",
toml_file, e
))),
}
}
Err(e) => Err(Error::i18n_failed(format!(
"Failed to read TOML file {:?}: {}",
toml_file, e
))),
}
}
/// Flatten nested TOML structure into dot-notation keys
///
/// Example: `[forms.registration] username = "Name"` → `"forms.registration.username": "Name"`
fn flatten_toml(map: toml::map::Map<String, toml::Value>, prefix: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
for (key, value) in map {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
toml::Value::String(s) => {
result.insert(full_key, s);
}
toml::Value::Table(table) => {
let nested = Self::flatten_toml(table, &full_key);
result.extend(nested);
}
toml::Value::Array(arr) => {
// For arrays, convert to JSON representation
if let Ok(json) = serde_json::to_string(&arr) {
result.insert(full_key, json);
}
}
_ => {
// For other types, convert to string
result.insert(full_key, value.to_string());
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flatten_toml() {
let mut map = toml::map::Map::new();
map.insert(
"forms".to_string(),
toml::Value::Table({
let mut t = toml::map::Map::new();
t.insert(
"registration".to_string(),
toml::Value::Table({
let mut t2 = toml::map::Map::new();
t2.insert("title".to_string(), toml::Value::String("Registration".to_string()));
t2
}),
);
t
}),
);
let flattened = LocaleLoader::flatten_toml(map, "");
assert_eq!(
flattened.get("forms.registration.title"),
Some(&"Registration".to_string())
);
}
}

View File

@ -0,0 +1,130 @@
//! Internationalization (i18n) support using Fluent and TOML
//!
//! Provides multi-language support for forms, prompts, and messages.
mod loader;
mod resolver;
pub use loader::LocaleLoader;
pub use resolver::LocaleResolver;
use crate::error::{Error, Result};
use fluent::FluentArgs;
use fluent_bundle::{FluentBundle, FluentResource};
use std::collections::HashMap;
use unic_langid::LanguageIdentifier;
/// I18n bundle that combines Fluent translations and TOML translations
pub struct I18nBundle {
bundle: FluentBundle<FluentResource>,
fallback_bundle: FluentBundle<FluentResource>,
toml_translations: HashMap<String, String>,
}
impl I18nBundle {
/// Create a new I18nBundle from a locale and fallback locale
pub fn new(
locale: LanguageIdentifier,
fallback_locale: LanguageIdentifier,
loader: &LocaleLoader,
) -> Result<Self> {
let bundle = Self::create_bundle(&locale, loader)?;
let fallback_bundle = Self::create_bundle(&fallback_locale, loader)?;
let toml_translations = loader.load_toml(&locale)?;
Ok(Self {
bundle,
fallback_bundle,
toml_translations,
})
}
/// Create a FluentBundle for a given locale
fn create_bundle(
locale: &LanguageIdentifier,
loader: &LocaleLoader,
) -> Result<FluentBundle<FluentResource>> {
let mut bundle = FluentBundle::new(vec![locale.clone()]);
for resource_str in loader.load_fluent(locale)? {
let resource = FluentResource::try_new(resource_str).map_err(|e| {
Error::i18n_failed(format!("Fluent parse error: {:?}", e))
})?;
bundle.add_resource(resource).map_err(|e| {
Error::i18n_failed(format!("Bundle add error: {:?}", e))
})?;
}
Ok(bundle)
}
/// Translate a message key
///
/// Searches in order: main bundle → TOML translations → fallback bundle → missing key marker
pub fn translate(&self, key: &str, args: Option<&FluentArgs>) -> String {
// Try main bundle
if let Some(msg) = self.bundle.get_message(key) {
if let Some(pattern) = msg.value() {
let mut errors = vec![];
return self.bundle.format_pattern(pattern, args, &mut errors).to_string();
}
}
// Try TOML translations
if let Some(translation) = self.toml_translations.get(key) {
return translation.clone();
}
// Try fallback bundle
if let Some(msg) = self.fallback_bundle.get_message(key) {
if let Some(pattern) = msg.value() {
let mut errors = vec![];
return self.fallback_bundle.format_pattern(pattern, args, &mut errors).to_string();
}
}
// Return missing marker for debugging
format!("[MISSING: {}]", key)
}
/// Check if a string looks like an i18n key
///
/// Heuristic: contains dots or starts with lowercase letter
pub fn is_i18n_key(text: &str) -> bool {
text.contains('.') || text.chars().next().is_some_and(|c| c.is_lowercase())
}
/// Translate if the text looks like a key, otherwise return as-is
pub fn translate_if_key(&self, text: &str, args: Option<&FluentArgs>) -> String {
if Self::is_i18n_key(text) {
self.translate(text, args)
} else {
text.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_i18n_key() {
assert!(I18nBundle::is_i18n_key("forms.title"));
assert!(I18nBundle::is_i18n_key("registration"));
assert!(!I18nBundle::is_i18n_key("Capitalized Text"));
assert!(!I18nBundle::is_i18n_key("Mixed Case String"));
}
#[test]
fn test_missing_translation() {
let loader = LocaleLoader::new(std::path::PathBuf::from("locales"));
// This will fail if locales dir doesn't exist, but that's OK for now
if let Ok(bundle) =
I18nBundle::new("en-US".parse().unwrap(), "en-US".parse().unwrap(), &loader)
{
let result = bundle.translate("nonexistent.key", None);
assert!(result.contains("MISSING"));
}
}
}

View File

@ -0,0 +1,134 @@
//! Locale resolution with priority-based fallback
use crate::config::TypeDialogConfig;
use unic_langid::LanguageIdentifier;
/// Resolver for determining which locale to use based on multiple sources
pub struct LocaleResolver {
config: TypeDialogConfig,
}
impl LocaleResolver {
/// Create a new locale resolver with the given configuration
pub fn new(config: TypeDialogConfig) -> Self {
Self { config }
}
/// Resolve the locale to use based on priority
///
/// Priority order:
/// 1. CLI flag `--locale`
/// 2. Form TOML attribute `locale`
/// 3. Global config `~/.config/typedialog/config.toml`
/// 4. Environment variable `TYPEDIALOG_LANG`
/// 5. Environment variables `LANG` or `LC_ALL`
/// 6. Fallback to "en-US"
pub fn resolve(
&self,
cli_locale: Option<&str>,
form_locale: Option<&str>,
) -> LanguageIdentifier {
// Priority 1: CLI flag
if let Some(locale) = cli_locale {
if let Ok(lang_id) = locale.parse() {
return lang_id;
}
}
// Priority 2: Form TOML attribute
if let Some(locale) = form_locale {
if let Ok(lang_id) = locale.parse() {
return lang_id;
}
}
// Priority 3: Global config
if let Some(locale) = &self.config.locale {
if let Ok(lang_id) = locale.parse() {
return lang_id;
}
}
// Priority 4: Environment variables
if let Some(lang_id) = self.detect_system_locale() {
return lang_id;
}
// Priority 5: Fallback
self.config
.fallback_locale
.parse()
.unwrap_or_else(|_| "en-US".parse().unwrap())
}
/// Detect system locale from environment variables
/// Priority: TYPEDIALOG_LANG > LANG > LC_ALL
fn detect_system_locale(&self) -> Option<LanguageIdentifier> {
// Priority 1: TYPEDIALOG_LANG (explicit typedialog-specific setting)
if let Ok(locale_str) = std::env::var("TYPEDIALOG_LANG") {
if let Ok(lang_id) = locale_str.parse() {
return Some(lang_id);
}
}
// Priority 2: Try LANG, then LC_ALL (system locale)
let locale_str = std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.ok()?;
// Extract just the language part (e.g., "en_US.UTF-8" → "en-US")
let parts: Vec<&str> = locale_str.split_whitespace().collect();
let base = parts.first().unwrap_or(&"en-US");
// Remove encoding (e.g., ".UTF-8")
let lang_part = base.split('.').next().unwrap_or(base);
// Convert underscore to hyphen (e.g., "en_US" → "en-US")
let normalized = lang_part.replace('_', "-");
normalized.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TypeDialogConfig;
#[test]
fn test_locale_resolution_priority() {
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config);
// CLI flag should have highest priority
let locale = resolver.resolve(Some("es-ES"), Some("fr-FR"));
assert_eq!(locale.to_string(), "es-ES");
// Form locale if no CLI flag
let locale = resolver.resolve(None, Some("fr-FR"));
assert_eq!(locale.to_string(), "fr-FR");
// Fallback if no CLI or form locale
let locale = resolver.resolve(None, None);
assert_eq!(locale.to_string(), "en-US");
}
#[test]
fn test_invalid_locale_format() {
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config);
// Invalid format should fall through to next priority
let locale = resolver.resolve(Some("invalid!!!"), Some("fr-FR"));
assert_eq!(locale.to_string(), "fr-FR");
}
#[test]
fn test_system_locale_parsing() {
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config);
// The actual system locale will vary, so just test parsing doesn't panic
let _locale = resolver.detect_system_locale();
}
}

View File

@ -0,0 +1,116 @@
//! typedialog - Interactive forms and prompts library
//!
//! A powerful library and CLI tool for creating interactive forms and prompts
//! with support for multiple rendering backends (CLI, TUI, Web).
//! Works with piped input for batch processing and scripts.
//!
//! # Features
//!
//! - 9 interactive prompt types (text, confirm, select, multi-select, password, custom, editor, date, form)
//! - TOML-based form definitions for declarative UI
//! - Multiple rendering backends: CLI (inquire), TUI (ratatui), Web (axum/HTMX)
//! - Stdin-based fallback for non-interactive contexts
//! - JSON/YAML output formats
//! - Nickel schema integration for configuration management
//! - Both library and CLI tool usage
//!
//! # Quick Start as Library
//!
//! ```no_run
//! use typedialog_core::prompts;
//!
//! // Simple text prompt
//! let name = prompts::text("Enter your name", None, None)?;
//! println!("Hello, {}!", name);
//!
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! # Quick Start with Backends
//!
//! ```ignore
//! use typedialog_core::backends::{BackendFactory, BackendType};
//! use typedialog_core::form_parser;
//!
//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let mut backend = BackendFactory::create(BackendType::Cli)?;
//! let form = form_parser::parse_toml("[[fields]]\nname = \"username\"\ntype = \"text\"\n")?;
//! let results = form_parser::execute_with_backend(form, &mut backend).await?;
//! Ok(())
//! }
//! ```
//!
//! # Quick Start as CLI
//!
//! ```bash
//! # Text prompt with piped input
//! echo "Alice" | typedialog text "Enter name"
//!
//! # Form execution
//! cat input.txt | typedialog form myform.toml
//!
//! # Selection
//! echo "Admin" | typedialog select "Choose role" Admin User Guest
//!
//! # Nickel integration
//! typedialog nickel-to-form schema.ncl -o form.toml --flatten
//! typedialog form-to-nickel form.toml results.json -o output.ncl --validate
//! ```
pub mod error;
pub mod form_parser;
pub mod helpers;
pub mod prompts;
pub mod autocompletion;
pub mod nickel;
pub mod backends;
#[cfg(feature = "i18n")]
pub mod config;
#[cfg(feature = "i18n")]
pub mod i18n;
#[cfg(feature = "templates")]
pub mod templates;
/// Common CLI patterns and help text
pub mod cli_common;
// Re-export main types for convenient access
pub use error::{Error, Result};
pub use form_parser::{FieldDefinition, FieldType, FormDefinition, DisplayItem};
pub use helpers::{format_results, to_json_value, to_json_string};
pub use autocompletion::{HistoryCompleter, FilterCompleter, PatternCompleter};
pub use backends::{FormBackend, BackendType, BackendFactory, RenderContext};
#[cfg(feature = "i18n")]
pub use config::TypeDialogConfig;
#[cfg(feature = "i18n")]
pub use i18n::{I18nBundle, LocaleResolver};
#[cfg(feature = "templates")]
pub use templates::{TemplateEngine, TemplateContextBuilder};
/// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!VERSION.is_empty());
}
#[test]
fn test_backend_factory_cli() {
let result = BackendFactory::create(BackendType::Cli);
#[cfg(feature = "cli")]
assert!(result.is_ok());
#[cfg(not(feature = "cli"))]
assert!(result.is_err());
}
}

View File

@ -0,0 +1,239 @@
//! Nickel CLI wrapper using std::process::Command
//!
//! Provides safe wrappers around nickel CLI commands:
//! - `nickel query` - Extract metadata from .ncl files
//! - `nickel export` - Export evaluated Nickel to JSON
//! - `nickel typecheck` - Validate Nickel syntax and types
use std::path::Path;
use std::process::Command;
use crate::error::{Error, ErrorKind};
use crate::Result;
/// Nickel CLI command wrapper
pub struct NickelCli;
impl NickelCli {
/// Verify that nickel CLI is installed and accessible
///
/// # Errors
///
/// Returns an error if `nickel` command is not found or cannot be executed.
pub fn verify() -> Result<String> {
let output = Command::new("nickel")
.arg("--version")
.output()
.map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to execute 'nickel --version'. \
Is nickel installed? Install from: https://nickel-lang.org/install\n\
Error: {}",
e
),
)
})?;
if !output.status.success() {
return Err(Error::new(
ErrorKind::Other,
"nickel command failed. Is nickel installed correctly?",
));
}
String::from_utf8(output.stdout).map_err(|e| {
Error::new(
ErrorKind::Other,
format!("Invalid UTF-8 in nickel version output: {}", e),
)
})
}
/// Execute `nickel query --format json` to extract metadata
///
/// # Arguments
///
/// * `path` - Path to the .ncl file
/// * `field` - Optional field path to query (e.g., "inputs", "inputs.user")
///
/// # Returns
///
/// JSON value containing the queried metadata
///
/// # Errors
///
/// Returns an error if the nickel command fails or output is invalid JSON.
pub fn query(path: &Path, field: Option<&str>) -> Result<serde_json::Value> {
let mut cmd = Command::new("nickel");
cmd.arg("query")
.arg("--format")
.arg("json")
.arg(path);
if let Some(f) = field {
cmd.arg("--field").arg(f);
}
let output = cmd.output().map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to execute 'nickel query' on {}: {}",
path.display(),
e
),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
ErrorKind::ValidationFailed,
format!(
"nickel query failed for {}: {}",
path.display(),
stderr
),
));
}
let stdout = String::from_utf8(output.stdout).map_err(|e| {
Error::new(
ErrorKind::Other,
format!("Invalid UTF-8 in nickel query output: {}", e),
)
})?;
serde_json::from_str(&stdout).map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to parse nickel query output as JSON: {}",
e
),
)
})
}
/// Execute `nickel export --format json` to export evaluated Nickel
///
/// # Arguments
///
/// * `path` - Path to the .ncl file
///
/// # Returns
///
/// JSON value containing the exported Nickel configuration
///
/// # Errors
///
/// Returns an error if the nickel command fails or output is invalid JSON.
pub fn export(path: &Path) -> Result<serde_json::Value> {
let output = Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(path)
.output()
.map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to execute 'nickel export' on {}: {}",
path.display(),
e
),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
ErrorKind::ValidationFailed,
format!(
"nickel export failed for {}: {}",
path.display(),
stderr
),
));
}
let stdout = String::from_utf8(output.stdout).map_err(|e| {
Error::new(
ErrorKind::Other,
format!("Invalid UTF-8 in nickel export output: {}", e),
)
})?;
serde_json::from_str(&stdout).map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to parse nickel export output as JSON: {}",
e
),
)
})
}
/// Execute `nickel typecheck` to validate Nickel file
///
/// # Arguments
///
/// * `path` - Path to the .ncl file to typecheck
///
/// # Returns
///
/// Ok if typecheck succeeds, Err with detailed message if it fails
///
/// # Errors
///
/// Returns an error if the nickel typecheck command fails.
pub fn typecheck(path: &Path) -> Result<()> {
let output = Command::new("nickel")
.arg("typecheck")
.arg(path)
.output()
.map_err(|e| {
Error::new(
ErrorKind::Other,
format!(
"Failed to execute 'nickel typecheck' on {}: {}",
path.display(),
e
),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
ErrorKind::ValidationFailed,
format!(
"nickel typecheck failed for {}:\n{}",
path.display(),
stderr
),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_nickel_installed() {
// This will fail if nickel is not installed, which is expected
let result = NickelCli::verify();
if result.is_ok() {
println!("Nickel is installed: {:?}", result);
} else {
eprintln!("Nickel not found: {:?}", result);
}
}
}

View File

@ -0,0 +1,323 @@
//! Contract Validator
//!
//! Validates Nickel contracts and predicates against JSON values.
//!
//! Supports common Nickel validation predicates:
//! - `std.string.NonEmpty` - Non-empty string
//! - `std.string.length.min N` - Minimum string length
//! - `std.string.length.max N` - Maximum string length
//! - `std.number.between A B` - Number in range [A, B]
//! - `std.number.greater_than N` - Number > N
//! - `std.number.less_than N` - Number < N
use crate::error::{Error, ErrorKind};
use crate::Result;
use serde_json::Value;
/// Validator for Nickel contracts and predicates
pub struct ContractValidator;
impl ContractValidator {
/// Validate a value against a Nickel contract
///
/// # Arguments
///
/// * `value` - The JSON value to validate
/// * `contract` - The Nickel contract string (e.g., "String | std.string.NonEmpty")
///
/// # Returns
///
/// Ok if validation succeeds, Err with descriptive message if it fails
pub fn validate(value: &Value, contract: &str) -> Result<()> {
// Extract the predicate from the contract (after the pipe)
let predicate = contract
.rfind('|')
.map(|i| contract[i + 1..].trim())
.unwrap_or(contract);
// Match common predicates
if predicate.contains("std.string.NonEmpty") {
return Self::validate_non_empty_string(value);
}
if predicate.contains("std.string.length.min") {
if let Some(n) = Self::extract_number(predicate, "std.string.length.min") {
return Self::validate_min_length(value, n);
}
}
if predicate.contains("std.string.length.max") {
if let Some(n) = Self::extract_number(predicate, "std.string.length.max") {
return Self::validate_max_length(value, n);
}
}
if predicate.contains("std.number.between") {
if let Some((a, b)) = Self::extract_range(predicate) {
return Self::validate_between(value, a, b);
}
}
if predicate.contains("std.number.greater_than") {
if let Some(n) = Self::extract_number(predicate, "std.number.greater_than") {
return Self::validate_greater_than(value, n as f64);
}
}
if predicate.contains("std.number.less_than") {
if let Some(n) = Self::extract_number(predicate, "std.number.less_than") {
return Self::validate_less_than(value, n as f64);
}
}
// Unknown predicate - pass validation
Ok(())
}
/// Validate that string is non-empty
fn validate_non_empty_string(value: &Value) -> Result<()> {
match value {
Value::String(s) => {
if s.is_empty() {
Err(Error::new(
ErrorKind::ValidationFailed,
"String must not be empty (std.string.NonEmpty)".to_string(),
))
} else {
Ok(())
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected string value".to_string(),
)),
}
}
/// Validate minimum string length
fn validate_min_length(value: &Value, min: usize) -> Result<()> {
match value {
Value::String(s) => {
if s.len() < min {
Err(Error::new(
ErrorKind::ValidationFailed,
format!("String must be at least {} characters (std.string.length.min {})", min, min),
))
} else {
Ok(())
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected string value".to_string(),
)),
}
}
/// Validate maximum string length
fn validate_max_length(value: &Value, max: usize) -> Result<()> {
match value {
Value::String(s) => {
if s.len() > max {
Err(Error::new(
ErrorKind::ValidationFailed,
format!("String must be at most {} characters (std.string.length.max {})", max, max),
))
} else {
Ok(())
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected string value".to_string(),
)),
}
}
/// Validate number is in range [a, b]
fn validate_between(value: &Value, a: f64, b: f64) -> Result<()> {
match value {
Value::Number(n) => {
if let Some(num) = n.as_f64() {
if num >= a && num <= b {
Ok(())
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
format!(
"Number must be between {} and {} (std.number.between {} {})",
a, b, a, b
),
))
}
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
"Invalid number value".to_string(),
))
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected number value".to_string(),
)),
}
}
/// Validate number is greater than n
fn validate_greater_than(value: &Value, n: f64) -> Result<()> {
match value {
Value::Number(num) => {
if let Some(val) = num.as_f64() {
if val > n {
Ok(())
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
format!("Number must be greater than {} (std.number.greater_than {})", n, n),
))
}
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
"Invalid number value".to_string(),
))
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected number value".to_string(),
)),
}
}
/// Validate number is less than n
fn validate_less_than(value: &Value, n: f64) -> Result<()> {
match value {
Value::Number(num) => {
if let Some(val) = num.as_f64() {
if val < n {
Ok(())
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
format!("Number must be less than {} (std.number.less_than {})", n, n),
))
}
} else {
Err(Error::new(
ErrorKind::ValidationFailed,
"Invalid number value".to_string(),
))
}
}
_ => Err(Error::new(
ErrorKind::ValidationFailed,
"Expected number value".to_string(),
)),
}
}
/// Extract a single number from predicate string
fn extract_number(predicate: &str, pattern: &str) -> Option<usize> {
let start = predicate.find(pattern)? + pattern.len();
let rest = &predicate[start..];
// Extract digits after the pattern
rest.split_whitespace()
.next()
.and_then(|s| s.trim_matches(|c: char| !c.is_ascii_digit()).parse().ok())
}
/// Extract a range (a, b) from between predicate
fn extract_range(predicate: &str) -> Option<(f64, f64)> {
// Parse patterns like "std.number.between 0 100" or "std.number.between 0.5 99.9"
let start = predicate.find("std.number.between")? + "std.number.between".len();
let rest = predicate[start..].trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let a = parts[0].parse::<f64>().ok()?;
let b = parts[1].parse::<f64>().ok()?;
Some((a, b))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_validate_non_empty_string() {
let result = ContractValidator::validate(&json!("hello"), "String | std.string.NonEmpty");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!(""), "String | std.string.NonEmpty");
assert!(result.is_err());
}
#[test]
fn test_validate_min_length() {
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.min 3");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.min 3");
assert!(result.is_err());
}
#[test]
fn test_validate_max_length() {
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.max 3");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.max 3");
assert!(result.is_err());
}
#[test]
fn test_validate_between() {
let result = ContractValidator::validate(&json!(50), "Number | std.number.between 0 100");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!(150), "Number | std.number.between 0 100");
assert!(result.is_err());
}
#[test]
fn test_validate_greater_than() {
let result = ContractValidator::validate(&json!(50), "Number | std.number.greater_than 10");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!(5), "Number | std.number.greater_than 10");
assert!(result.is_err());
}
#[test]
fn test_validate_less_than() {
let result = ContractValidator::validate(&json!(5), "Number | std.number.less_than 10");
assert!(result.is_ok());
let result = ContractValidator::validate(&json!(50), "Number | std.number.less_than 10");
assert!(result.is_err());
}
#[test]
fn test_extract_range() {
let range = ContractValidator::extract_range("std.number.between 0 100");
assert_eq!(range, Some((0.0, 100.0)));
let range = ContractValidator::extract_range("std.number.between 0.5 99.9");
assert_eq!(range, Some((0.5, 99.9)));
}
#[test]
fn test_unknown_predicate_passes() {
let result = ContractValidator::validate(&json!("anything"), "String | some.unknown.predicate");
assert!(result.is_ok());
}
}

View File

@ -0,0 +1,46 @@
//! Nickel integration for typedialog
//!
//! This module provides bidirectional integration between Nickel configuration schemas
//! and typedialog interactive forms:
//!
//! 1. **Nickel → Form**: Extract metadata from Nickel schemas and generate TOML forms
//! 2. **Form Execution**: Run interactive forms (existing typedialog functionality)
//! 3. **Results → Nickel**: Serialize form results back to valid Nickel with contracts
//! 4. **Template Metaprogramming**: Render .ncl.j2 templates to generate Nickel code
//!
//! # Example
//!
//! ```ignore
//! use typedialog::nickel::NickelCli;
//! use std::path::Path;
//!
//! // Verify nickel CLI is available
//! NickelCli::verify()?;
//!
//! // Extract metadata from schema
//! let metadata = NickelCli::query(Path::new("schema.ncl"), Some("inputs"))?;
//!
//! // Parse into intermediate representation
//! let schema_ir = MetadataParser::parse(metadata)?;
//!
//! // Generate TOML form
//! let form = TomlGenerator::generate(&schema_ir)?;
//! ```
pub mod cli;
pub mod schema_ir;
pub mod parser;
pub mod toml_generator;
pub mod serializer;
pub mod template_engine;
pub mod contracts;
pub mod types;
pub use cli::NickelCli;
pub use schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
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;

View File

@ -0,0 +1,254 @@
//! Metadata Parser
//!
//! Parses JSON output from `nickel query` into NickelSchemaIR.
//!
//! Handles extraction of metadata from Nickel schemas including:
//! - Type contracts and annotations
//! - Documentation (| doc)
//! - Default values (| default)
//! - Optional fields (| optional)
//! - Nested record structures
use crate::error::{Error, ErrorKind};
use crate::Result;
use serde_json::Value;
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
/// Parser for Nickel metadata JSON from `nickel query` output
pub struct MetadataParser;
impl MetadataParser {
/// Parse JSON metadata from nickel query into NickelSchemaIR
///
/// # Arguments
///
/// * `json` - JSON value from `nickel query --format json`
///
/// # Returns
///
/// NickelSchemaIR with parsed fields, types, and metadata
///
/// # Errors
///
/// Returns error if JSON structure is invalid or required fields are missing
pub fn parse(json: Value) -> Result<NickelSchemaIR> {
let obj = json
.as_object()
.ok_or_else(|| Error::new(
ErrorKind::ValidationFailed,
"Expected JSON object from nickel query",
))?;
let mut fields = Vec::new();
Self::extract_fields(obj, Vec::new(), &mut fields)?;
Ok(NickelSchemaIR {
name: "generated_schema".to_string(),
description: None,
fields,
})
}
/// Recursively extract fields from JSON object
fn extract_fields(
obj: &serde_json::Map<String, Value>,
path: Vec<String>,
fields: &mut Vec<NickelFieldIR>,
) -> Result<()> {
for (key, value) in obj {
let mut field_path = path.clone();
field_path.push(key.clone());
// For nested objects, recursively extract leaf fields
if let Value::Object(nested_obj) = value {
Self::extract_fields(nested_obj, field_path, fields)?;
} else {
// Parse field metadata and type
let field = Self::parse_field_value(key, value, field_path)?;
fields.push(field);
}
}
Ok(())
}
/// Parse a single field from its JSON value
fn parse_field_value(
_name: &str,
value: &Value,
path: Vec<String>,
) -> Result<NickelFieldIR> {
let flat_name = path.join("_");
// Extract metadata from the value
let doc = Self::extract_doc(value);
let default = Self::extract_default(value);
let optional = Self::extract_optional(value);
let contract = Self::extract_contract(value);
let nickel_type = Self::infer_type(value, &path);
Ok(NickelFieldIR {
path,
flat_name,
nickel_type,
doc,
default,
optional,
contract,
group: None, // Will be assigned during form generation
})
}
/// Extract documentation from field value
fn extract_doc(value: &Value) -> Option<String> {
if let Value::Object(obj) = value {
// Look for doc metadata
if let Some(Value::String(doc)) = obj.get("doc") {
return Some(doc.clone());
}
// Check metadata field
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
if let Some(Value::String(doc)) = meta_obj.get("doc") {
return Some(doc.clone());
}
}
}
None
}
/// Extract default value from field value
fn extract_default(value: &Value) -> Option<serde_json::Value> {
if let Value::Object(obj) = value {
// Look for default value
if let Some(default) = obj.get("default") {
return Some(default.clone());
}
// Check metadata field
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
if let Some(default) = meta_obj.get("default") {
return Some(default.clone());
}
}
}
None
}
/// Extract optional flag from field value
fn extract_optional(value: &Value) -> bool {
if let Value::Object(obj) = value {
// Check optional flag
if let Some(Value::Bool(opt)) = obj.get("optional") {
return *opt;
}
// Check metadata field
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
if let Some(Value::Bool(opt)) = meta_obj.get("optional") {
return *opt;
}
}
}
false
}
/// Extract Nickel contract/predicate from field value
fn extract_contract(value: &Value) -> Option<String> {
if let Value::Object(obj) = value {
// Look for contract annotation
if let Some(Value::String(contract)) = obj.get("contract") {
return Some(contract.clone());
}
// Check type field for contract info
if let Some(Value::String(type_str)) = obj.get("type") {
if type_str.contains("|") {
return Some(type_str.clone());
}
}
}
None
}
/// Infer Nickel type from JSON value
fn infer_type(value: &Value, _path: &[String]) -> NickelType {
match value {
Value::Null => NickelType::Custom("unknown".to_string()),
Value::Bool(_) => NickelType::Bool,
Value::Number(_) => NickelType::Number,
Value::String(_) => NickelType::String,
Value::Array(arr) => {
// Infer array element type from first element
if let Some(elem) = arr.first() {
let elem_type = Self::infer_type(elem, &[]);
NickelType::Array(Box::new(elem_type))
} else {
NickelType::Array(Box::new(NickelType::Custom("unknown".to_string())))
}
}
Value::Object(obj) => {
// For nested objects, extract fields
let mut nested_fields = Vec::new();
let path_copy = _path.to_vec();
let _ = Self::extract_fields(obj, path_copy, &mut nested_fields);
NickelType::Record(nested_fields)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_simple_field() {
let json = json!({
"user": {
"name": "Alice",
"age": 30
}
});
let result = MetadataParser::parse(json);
assert!(result.is_ok());
let schema = result.unwrap();
// Flattened: user_name and user_age
assert_eq!(schema.fields.len(), 2);
let flat_names: Vec<_> = schema.fields.iter().map(|f| f.flat_name.as_str()).collect();
assert!(flat_names.contains(&"user_name"));
assert!(flat_names.contains(&"user_age"));
}
#[test]
fn test_parse_with_optional() {
let json = json!({
"settings": {
"email": "test@example.com"
}
});
let result = MetadataParser::parse(json);
assert!(result.is_ok());
let schema = result.unwrap();
// Should have settings_email field
assert_eq!(schema.fields.len(), 1);
assert_eq!(schema.fields[0].flat_name, "settings_email");
}
#[test]
fn test_extract_doc() {
let value = json!({
"doc": "Test documentation"
});
let doc = MetadataParser::extract_doc(&value);
assert_eq!(doc, Some("Test documentation".to_string()));
}
#[test]
fn test_extract_default() {
let value = json!({
"default": 42
});
let default = MetadataParser::extract_default(&value);
assert_eq!(default, Some(json!(42)));
}
}

View File

@ -0,0 +1,108 @@
//! Nickel Schema Intermediate Representation
//!
//! Defines the normalized representation of a Nickel schema extracted from
//! `nickel query` output, independent of the Nickel AST.
use serde::{Deserialize, Serialize};
/// Intermediate representation of a Nickel schema
///
/// Contains the parsed structure of a Nickel configuration schema including
/// field definitions, types, and metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickelSchemaIR {
/// Schema name/identifier
pub name: String,
/// Optional description from | doc
pub description: Option<String>,
/// All fields in the schema
pub fields: Vec<NickelFieldIR>,
}
/// Intermediate representation of a single field in a Nickel schema
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickelFieldIR {
/// Path to field: ["user", "name"] for user.name
pub path: Vec<String>,
/// Flattened name: "user_name" for user.name
pub flat_name: String,
/// Nickel type information
pub nickel_type: NickelType,
/// Documentation from | doc
pub doc: Option<String>,
/// Default value from | default
pub default: Option<serde_json::Value>,
/// Whether field is optional (| optional)
pub optional: bool,
/// Nickel contract/predicate (e.g., "std.string.NonEmpty")
pub contract: Option<String>,
/// Semantic grouping for form UI
pub group: Option<String>,
}
/// Nickel type information for a field
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NickelType {
/// String type
String,
/// Number type
Number,
/// Boolean type
Bool,
/// Array type with element type
Array(Box<NickelType>),
/// Nested record with fields
Record(Vec<NickelFieldIR>),
/// Custom/unknown type
Custom(String),
}
impl NickelSchemaIR {
/// Create a new schema with given name and fields
pub fn new(name: String, fields: Vec<NickelFieldIR>) -> Self {
Self {
name,
description: None,
fields,
}
}
/// Find a field by its flat name
pub fn find_field(&self, flat_name: &str) -> Option<&NickelFieldIR> {
self.fields.iter().find(|f| f.flat_name == flat_name)
}
/// Get all fields in a specific group
pub fn fields_by_group(&self, group: &str) -> Vec<&NickelFieldIR> {
self.fields
.iter()
.filter(|f| f.group.as_deref() == Some(group))
.collect()
}
/// Get all unique groups in the schema
pub fn groups(&self) -> Vec<String> {
let mut groups: Vec<_> = self
.fields
.iter()
.filter_map(|f| f.group.clone())
.collect();
groups.sort();
groups.dedup();
groups
}
}

View File

@ -0,0 +1,356 @@
//! Nickel Serializer
//!
//! Serializes form results back to valid Nickel code with contracts and metadata.
//!
//! Handles:
//! - Unflattening flat field names back to nested record structure
//! - Type annotations (String, Number, Bool, Array)
//! - Nickel contracts and predicates
//! - Documentation comments from original schema
//! - Pretty printing with proper indentation
use crate::Result;
use serde_json::Value;
use std::collections::HashMap;
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
/// Serializer for converting form results to Nickel output
pub struct NickelSerializer;
impl NickelSerializer {
/// Serialize HashMap results to Nickel output with contracts and metadata
///
/// # Arguments
///
/// * `results` - HashMap of field names to values from form execution
/// * `schema` - The Nickel schema IR with field metadata
///
/// # Returns
///
/// Valid Nickel code as string
pub fn serialize(
results: &HashMap<String, Value>,
schema: &NickelSchemaIR,
) -> Result<String> {
// Build nested structure from flat results
let nested = Self::unflatten_results(results, schema);
// Serialize to Nickel with contracts and docs
let nickel_string = Self::serialize_value(&nested, schema, 0);
Ok(nickel_string)
}
/// Unflatten HashMap results into nested structure
fn unflatten_results(
results: &HashMap<String, Value>,
schema: &NickelSchemaIR,
) -> Value {
let mut root = serde_json::json!({});
for field in &schema.fields {
if let Some(value) = results.get(&field.flat_name) {
// Navigate/create nested structure and insert value
Self::insert_nested(&mut root, &field.path, value.clone());
}
}
root
}
/// Insert a value into nested structure using path
fn insert_nested(obj: &mut Value, path: &[String], value: Value) {
if path.is_empty() {
return;
}
if path.len() == 1 {
obj[&path[0]] = value;
return;
}
// Ensure intermediate objects exist
for i in 0..path.len() - 1 {
if !obj[&path[i]].is_object() {
obj[&path[i]] = serde_json::json!({});
}
}
// Navigate to parent and insert
let mut current = obj;
for i in 0..path.len() - 1 {
current = &mut current[&path[i]];
}
current[&path[path.len() - 1]] = value;
}
/// Serialize a value to Nickel code with type annotations
fn serialize_value(value: &Value, schema: &NickelSchemaIR, indent: usize) -> String {
match value {
Value::Object(map) => {
let indent_str = " ".repeat(indent);
let inner_indent_str = " ".repeat(indent + 1);
if map.is_empty() {
return "{}".to_string();
}
let mut lines = vec!["{\n".to_string()];
for (key, val) in map {
// Find field metadata for type annotation
if let Some(field_meta) = Self::find_field_for_key(schema, key) {
// Add doc comment if present
if let Some(doc) = &field_meta.doc {
lines.push(format!("{}# {}\n", inner_indent_str, doc));
}
// Build field with type annotation and contract
let type_annotation = Self::build_type_annotation(&field_meta);
let contract = field_meta.contract.as_ref().map(|c| format!(" | {}", c)).unwrap_or_default();
let field_value = Self::serialize_value(val, schema, indent + 1);
let field_line = if field_value.contains('\n') {
format!("{}{} : {}{} = {}", inner_indent_str, key, type_annotation, contract, field_value)
} else {
format!("{}{} : {}{} = {},\n", inner_indent_str, key, type_annotation, contract, field_value)
};
lines.push(field_line);
} else {
// Fallback without metadata
let field_value = Self::serialize_value(val, schema, indent + 1);
let field_line = format!("{}{} = {},\n", inner_indent_str, key, field_value);
lines.push(field_line);
}
}
lines.push(format!("{}}}", indent_str));
lines.join("")
}
Value::Array(arr) => {
let indent_str = " ".repeat(indent);
let inner_indent_str = " ".repeat(indent + 1);
if arr.is_empty() {
return "[]".to_string();
}
let mut lines = vec!["[\n".to_string()];
for item in arr {
let item_str = Self::serialize_value(item, schema, indent + 1);
lines.push(format!("{}{},\n", inner_indent_str, item_str));
}
lines.push(format!("{}]", indent_str));
lines.join("")
}
Value::String(s) => format!("\"{}\"", escape_string(s)),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
}
}
/// Build type annotation from field metadata
fn build_type_annotation(field: &NickelFieldIR) -> String {
match &field.nickel_type {
NickelType::String => "String".to_string(),
NickelType::Number => "Number".to_string(),
NickelType::Bool => "Bool".to_string(),
NickelType::Array(elem_type) => {
let elem_annotation = Self::type_to_nickel(elem_type);
format!("[{}]", elem_annotation)
}
NickelType::Record(_) => "{...}".to_string(),
NickelType::Custom(name) => name.clone(),
}
}
/// Convert NickelType to Nickel type annotation
fn type_to_nickel(nickel_type: &NickelType) -> String {
match nickel_type {
NickelType::String => "String".to_string(),
NickelType::Number => "Number".to_string(),
NickelType::Bool => "Bool".to_string(),
NickelType::Array(elem) => {
format!("[{}]", Self::type_to_nickel(elem))
}
NickelType::Record(_) => "{...}".to_string(),
NickelType::Custom(name) => name.clone(),
}
}
/// Find field metadata for a key
fn find_field_for_key(schema: &NickelSchemaIR, key: &str) -> Option<NickelFieldIR> {
schema.fields.iter().find(|f| f.path.last().is_some_and(|p| p == key)).cloned()
}
}
/// Escape string for Nickel double quotes
fn escape_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_serialize_simple_values() {
let mut results = HashMap::new();
results.insert("name".to_string(), json!("Alice"));
results.insert("age".to_string(), json!(30));
results.insert("active".to_string(), json!(true));
let schema = NickelSchemaIR {
name: "test".to_string(),
description: None,
fields: vec![
NickelFieldIR {
path: vec!["name".to_string()],
flat_name: "name".to_string(),
nickel_type: NickelType::String,
doc: Some("User name".to_string()),
default: None,
optional: false,
contract: Some("String | std.string.NonEmpty".to_string()),
group: None,
},
NickelFieldIR {
path: vec!["age".to_string()],
flat_name: "age".to_string(),
nickel_type: NickelType::Number,
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
NickelFieldIR {
path: vec!["active".to_string()],
flat_name: "active".to_string(),
nickel_type: NickelType::Bool,
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
],
};
let output = NickelSerializer::serialize(&results, &schema).unwrap();
// Check that output contains key information
assert!(output.contains("name"));
assert!(output.contains("Alice"));
assert!(output.contains("age"));
assert!(output.contains("30"));
assert!(output.contains("active"));
assert!(output.contains("true"));
assert!(output.contains("String | std.string.NonEmpty"));
}
#[test]
fn test_unflatten_nested() {
let mut results = HashMap::new();
results.insert("user_name".to_string(), json!("Alice"));
results.insert("user_email".to_string(), json!("alice@example.com"));
results.insert("settings_theme".to_string(), json!("dark"));
let schema = NickelSchemaIR {
name: "test".to_string(),
description: None,
fields: vec![
NickelFieldIR {
path: vec!["user".to_string(), "name".to_string()],
flat_name: "user_name".to_string(),
nickel_type: NickelType::String,
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
NickelFieldIR {
path: vec!["user".to_string(), "email".to_string()],
flat_name: "user_email".to_string(),
nickel_type: NickelType::String,
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
NickelFieldIR {
path: vec!["settings".to_string(), "theme".to_string()],
flat_name: "settings_theme".to_string(),
nickel_type: NickelType::String,
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
],
};
let output = NickelSerializer::serialize(&results, &schema).unwrap();
// Check nested structure is created
assert!(output.contains("user"));
assert!(output.contains("settings"));
assert!(output.contains("Alice"));
assert!(output.contains("alice@example.com"));
assert!(output.contains("dark"));
}
#[test]
fn test_escape_string() {
assert_eq!(escape_string("hello"), "hello");
assert_eq!(escape_string("hello\"world"), "hello\\\"world");
assert_eq!(escape_string("hello\\world"), "hello\\\\world");
assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn test_type_to_nickel() {
let string_type = NickelSerializer::type_to_nickel(&NickelType::String);
assert_eq!(string_type, "String");
let array_type = NickelSerializer::type_to_nickel(&NickelType::Array(Box::new(NickelType::String)));
assert_eq!(array_type, "[String]");
}
#[test]
fn test_serialize_with_arrays() {
let mut results = HashMap::new();
results.insert("tags".to_string(), json!(["rust", "nickel", "forms"]));
let schema = NickelSchemaIR {
name: "test".to_string(),
description: None,
fields: vec![
NickelFieldIR {
path: vec!["tags".to_string()],
flat_name: "tags".to_string(),
nickel_type: NickelType::Array(Box::new(NickelType::String)),
doc: None,
default: None,
optional: false,
contract: None,
group: None,
},
],
};
let output = NickelSerializer::serialize(&results, &schema).unwrap();
assert!(output.contains("rust"));
assert!(output.contains("nickel"));
assert!(output.contains("forms"));
assert!(output.contains("["));
assert!(output.contains("]"));
}
}

View File

@ -0,0 +1,207 @@
//! Template Engine
//!
//! Renders Tera templates for Nickel metaprogramming (.ncl.j2 files).
//!
//! Supports:
//! - Loading and rendering .ncl.j2 templates
//! - Template loops, conditionals, and filters
//! - Passing form results as context to templates
use crate::error::{Error, ErrorKind};
use crate::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::fs;
#[cfg(feature = "templates")]
use tera::{Tera, Context};
/// Template engine for rendering .ncl.j2 templates
pub struct TemplateEngine {
#[cfg(feature = "templates")]
tera: Tera,
}
impl TemplateEngine {
/// Create a new template engine
pub fn new() -> Self {
#[cfg(feature = "templates")]
{
let tera = Tera::default();
TemplateEngine { tera }
}
#[cfg(not(feature = "templates"))]
{
TemplateEngine {}
}
}
/// Render a template file with given values
pub fn render_file(
&mut self,
template_path: &Path,
values: &HashMap<String, Value>,
) -> Result<String> {
#[cfg(feature = "templates")]
{
// Read template file
let template_content = fs::read_to_string(template_path)
.map_err(|e| Error::new(
ErrorKind::Io,
format!("Failed to read template file: {}", e),
))?;
// Add template to engine
let template_name = template_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("template");
self.tera.add_raw_template(template_name, &template_content)
.map_err(|e| Error::new(
ErrorKind::ValidationFailed,
format!("Failed to add template: {}", e),
))?;
// Build context from values
let mut context = Context::new();
for (key, value) in values {
context.insert(key, value);
}
// Render template
self.tera.render(template_name, &context)
.map_err(|e| Error::new(
ErrorKind::ValidationFailed,
format!("Failed to render template: {}", e),
))
}
#[cfg(not(feature = "templates"))]
{
Err(Error::new(
ErrorKind::Other,
"Template feature not enabled. Enable with --features templates".to_string(),
))
}
}
/// Render a template string with given values
pub fn render_str(
&mut self,
template: &str,
values: &HashMap<String, Value>,
) -> Result<String> {
#[cfg(feature = "templates")]
{
// Add template to engine
self.tera.add_raw_template("inline", template)
.map_err(|e| Error::new(
ErrorKind::ValidationFailed,
format!("Failed to add template: {}", e),
))?;
// Build context from values
let mut context = Context::new();
for (key, value) in values {
context.insert(key, value);
}
// Render template
self.tera.render("inline", &context)
.map_err(|e| Error::new(
ErrorKind::ValidationFailed,
format!("Failed to render template: {}", e),
))
}
#[cfg(not(feature = "templates"))]
{
Err(Error::new(
ErrorKind::Other,
"Template feature not enabled. Enable with --features templates".to_string(),
))
}
}
}
impl Default for TemplateEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[cfg(feature = "templates")]
#[test]
fn test_template_engine_new() {
let _engine = TemplateEngine::new();
// Just verify it can be created
assert!(true);
}
#[cfg(feature = "templates")]
#[test]
fn test_render_simple_template() {
let mut engine = TemplateEngine::new();
let mut values = HashMap::new();
values.insert("name".to_string(), json!("Alice"));
values.insert("age".to_string(), json!(30));
let template = r#"
name : String = "{{ name }}"
age : Number = {{ age }}
"#;
let result = engine.render_str(template, &values);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("Alice"));
assert!(output.contains("30"));
}
#[cfg(feature = "templates")]
#[test]
fn test_render_with_loop() {
let mut engine = TemplateEngine::new();
let mut values = HashMap::new();
let tags = vec![json!("rust"), json!("nickel"), json!("forms")];
values.insert("tags".to_string(), Value::Array(tags));
let template = r#"tags = [
{% for tag in tags %}
"{{ tag }}",
{% endfor %}
]"#;
let result = engine.render_str(template, &values);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("rust"));
assert!(output.contains("nickel"));
}
#[cfg(feature = "templates")]
#[test]
fn test_render_with_conditional() {
let mut engine = TemplateEngine::new();
let mut values = HashMap::new();
values.insert("enabled".to_string(), json!(true));
values.insert("feature".to_string(), json!("monitoring"));
let template = r#"{% if enabled %}
{{ feature }}_enabled : Bool = true
{% endif %}"#;
let result = engine.render_str(template, &values);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("monitoring_enabled"));
}
}

View File

@ -0,0 +1,413 @@
//! TOML Form Generator
//!
//! Converts Nickel schema intermediate representation (NickelSchemaIR)
//! into typedialog FormDefinition TOML format.
//!
//! Handles type mapping, metadata extraction, flatten/unflatten operations,
//! and semantic grouping for form UI organization.
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
use crate::form_parser::{FormDefinition, FieldDefinition, FieldType, DisplayItem};
use crate::error::Result;
use std::collections::HashMap;
/// Generator for converting Nickel schemas to typedialog TOML forms
pub struct TomlGenerator;
impl TomlGenerator {
/// Convert a Nickel schema IR to a typedialog FormDefinition
///
/// # Arguments
///
/// * `schema` - The Nickel schema intermediate representation
/// * `flatten_records` - Whether to flatten nested records into flat field names
/// * `use_groups` - Whether to use semantic grouping for form organization
///
/// # Returns
///
/// FormDefinition ready to be serialized to TOML
pub fn generate(
schema: &NickelSchemaIR,
flatten_records: bool,
use_groups: bool,
) -> Result<FormDefinition> {
let mut fields = Vec::new();
let mut items = Vec::new();
let mut group_order: HashMap<String, usize> = HashMap::new();
let mut current_order = 0;
// First pass: collect all groups
if use_groups {
for field in &schema.fields {
if let Some(group) = &field.group {
group_order.entry(group.clone()).or_insert_with(|| {
let order = current_order;
current_order += 1;
order
});
}
}
}
// Generate display items for groups (headers)
let mut item_order = 0;
if use_groups {
for group in &schema.fields.iter().filter_map(|f| f.group.as_ref()).collect::<std::collections::HashSet<_>>() {
items.push(DisplayItem {
name: format!("{}_header", group),
item_type: "section".to_string(),
title: Some(format_group_title(group)),
border_top: Some(true),
group: Some(group.to_string()),
order: item_order,
content: None,
template: None,
border_bottom: None,
margin_left: None,
border_margin_left: None,
content_margin_left: None,
align: None,
when: None,
includes: 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,
});
item_order += 1;
}
}
// Second pass: generate fields
let mut field_order = item_order + 100; // Offset to allow items to display first
for field in &schema.fields {
let form_field = Self::field_ir_to_definition(
field,
flatten_records,
field_order,
)?;
fields.push(form_field);
field_order += 1;
}
Ok(FormDefinition {
name: schema.name.clone(),
description: schema.description.clone(),
fields,
items,
locale: None,
template: None,
output_template: None,
i18n_prefix: None,
display_mode: Default::default(),
})
}
/// Convert a single NickelFieldIR to a FieldDefinition
fn field_ir_to_definition(
field: &NickelFieldIR,
_flatten_records: bool,
order: usize,
) -> Result<FieldDefinition> {
let (field_type, custom_type) = Self::nickel_type_to_field_type(&field.nickel_type)?;
let prompt = field
.doc
.clone()
.unwrap_or_else(|| format_prompt_from_path(&field.flat_name));
let default = field.default.as_ref().map(|v| {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
});
let options = match &field.nickel_type {
NickelType::Array(_) => {
// Try to extract enum options from array element type or doc
Self::extract_enum_options(field)
}
_ => None,
};
// Determine if field is required
let required = if field.optional {
Some(false)
} else {
Some(true)
};
Ok(FieldDefinition {
name: field.flat_name.clone(),
field_type,
prompt,
default,
placeholder: None,
options,
required,
file_extension: None,
prefix_text: None,
page_size: None,
vim_mode: None,
custom_type,
min_date: None,
max_date: None,
week_start: None,
order,
when: None,
i18n: None,
group: field.group.clone(),
nickel_contract: field.contract.clone(),
nickel_path: Some(field.path.clone()),
nickel_doc: field.doc.clone(),
})
}
/// Map a Nickel type to typedialog field type
fn nickel_type_to_field_type(nickel_type: &NickelType) -> Result<(FieldType, Option<String>)> {
match nickel_type {
NickelType::String => Ok((FieldType::Text, None)),
NickelType::Number => Ok((FieldType::Custom, Some("f64".to_string()))),
NickelType::Bool => Ok((FieldType::Confirm, None)),
NickelType::Array(_) => {
// Default to editor for JSON array editing; can be changed to select/multiselect
// if options are detected
Ok((FieldType::Editor, Some("json".to_string())))
}
NickelType::Record(_) => {
// Records are handled by nested field generation
Ok((FieldType::Text, None))
}
NickelType::Custom(type_name) => {
// Unknown types map to custom with type name
Ok((FieldType::Custom, Some(type_name.clone())))
}
}
}
/// Extract enum options from field documentation or array structure
fn extract_enum_options(field: &NickelFieldIR) -> Option<Vec<String>> {
// Check if doc contains "Options: X, Y, Z" pattern
if let Some(doc) = &field.doc {
if let Some(start) = doc.find("Options:") {
let options_str = &doc[start + 8..]; // Skip "Options:"
let options: Vec<String> = options_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !options.is_empty() {
return Some(options);
}
}
}
// For now, don't try to extract from array structure unless we have more info
None
}
}
/// Format a group title from group name
fn format_group_title(group: &str) -> String {
// Convert snake_case or kebab-case to Title Case
group
.split(['_', '-'])
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Format a prompt from field name
fn format_prompt_from_path(flat_name: &str) -> String {
// Convert snake_case to Title Case
flat_name
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_generate_simple_schema() {
let schema = NickelSchemaIR {
name: "test_schema".to_string(),
description: Some("A test schema".to_string()),
fields: vec![
NickelFieldIR {
path: vec!["name".to_string()],
flat_name: "name".to_string(),
nickel_type: NickelType::String,
doc: Some("User full name".to_string()),
default: Some(json!("Alice")),
optional: false,
contract: Some("String | std.string.NonEmpty".to_string()),
group: None,
},
NickelFieldIR {
path: vec!["age".to_string()],
flat_name: "age".to_string(),
nickel_type: NickelType::Number,
doc: Some("User age".to_string()),
default: None,
optional: true,
contract: None,
group: None,
},
],
};
let form = TomlGenerator::generate(&schema, false, false).unwrap();
assert_eq!(form.name, "test_schema");
assert_eq!(form.fields.len(), 2);
// Check first field
assert_eq!(form.fields[0].name, "name");
assert_eq!(form.fields[0].field_type, FieldType::Text);
assert_eq!(form.fields[0].required, Some(true));
assert_eq!(form.fields[0].nickel_contract, Some("String | std.string.NonEmpty".to_string()));
// Check second field
assert_eq!(form.fields[1].name, "age");
assert_eq!(form.fields[1].field_type, FieldType::Custom);
assert_eq!(form.fields[1].custom_type, Some("f64".to_string()));
assert_eq!(form.fields[1].required, Some(false));
}
#[test]
fn test_generate_with_groups() {
let schema = NickelSchemaIR {
name: "grouped_schema".to_string(),
description: None,
fields: vec![
NickelFieldIR {
path: vec!["user_name".to_string()],
flat_name: "user_name".to_string(),
nickel_type: NickelType::String,
doc: Some("User name".to_string()),
default: None,
optional: false,
contract: None,
group: Some("user".to_string()),
},
NickelFieldIR {
path: vec!["settings_theme".to_string()],
flat_name: "settings_theme".to_string(),
nickel_type: NickelType::String,
doc: Some("Theme preference".to_string()),
default: Some(json!("dark")),
optional: false,
contract: None,
group: Some("settings".to_string()),
},
],
};
let form = TomlGenerator::generate(&schema, false, true).unwrap();
// Should have display items for groups
assert!(form.items.len() > 0);
// Check fields are grouped
assert_eq!(form.fields[0].group, Some("user".to_string()));
assert_eq!(form.fields[1].group, Some("settings".to_string()));
}
#[test]
fn test_nickel_type_to_field_type() {
let (field_type, custom_type) =
TomlGenerator::nickel_type_to_field_type(&NickelType::String).unwrap();
assert_eq!(field_type, FieldType::Text);
assert_eq!(custom_type, None);
let (field_type, custom_type) =
TomlGenerator::nickel_type_to_field_type(&NickelType::Number).unwrap();
assert_eq!(field_type, FieldType::Custom);
assert_eq!(custom_type, Some("f64".to_string()));
let (field_type, custom_type) =
TomlGenerator::nickel_type_to_field_type(&NickelType::Bool).unwrap();
assert_eq!(field_type, FieldType::Confirm);
assert_eq!(custom_type, None);
}
#[test]
fn test_format_group_title() {
assert_eq!(format_group_title("user"), "User");
assert_eq!(format_group_title("user_settings"), "User Settings");
assert_eq!(format_group_title("api-config"), "Api Config");
}
#[test]
fn test_format_prompt_from_path() {
assert_eq!(format_prompt_from_path("name"), "Name");
assert_eq!(format_prompt_from_path("user_name"), "User Name");
assert_eq!(format_prompt_from_path("first_name"), "First Name");
}
#[test]
fn test_extract_enum_options() {
let field = NickelFieldIR {
path: vec!["status".to_string()],
flat_name: "status".to_string(),
nickel_type: NickelType::Array(Box::new(NickelType::String)),
doc: Some("Status. Options: pending, active, completed".to_string()),
default: None,
optional: false,
contract: None,
group: None,
};
let options = TomlGenerator::extract_enum_options(&field);
assert_eq!(
options,
Some(vec![
"pending".to_string(),
"active".to_string(),
"completed".to_string(),
])
);
}
#[test]
fn test_default_value_conversion() {
let field = NickelFieldIR {
path: vec!["count".to_string()],
flat_name: "count".to_string(),
nickel_type: NickelType::Number,
doc: None,
default: Some(json!(42)),
optional: false,
contract: None,
group: None,
};
let form_field = TomlGenerator::field_ir_to_definition(&field, false, 0).unwrap();
assert_eq!(form_field.default, Some("42".to_string()));
}
}

View File

@ -0,0 +1,16 @@
//! Type Mapping
//!
//! Maps Nickel types to typedialog field types.
use super::schema_ir::NickelType;
/// Type mapper from Nickel types to typedialog field types
pub struct TypeMapper;
impl TypeMapper {
/// Map a Nickel type to a typedialog field type
pub fn map_type(_nickel_type: &NickelType) -> String {
// TODO: Implement in subsequent tasks
todo!()
}
}

View File

@ -0,0 +1,639 @@
//! Interactive prompt functions
//!
//! Provides high-level functions for all prompt types with automatic
//! fallback to stdin when interactive mode isn't available.
use crate::error::{Error, Result};
use chrono::{NaiveDate, Weekday};
use inquire::{Confirm, DateSelect, Editor as InquireEditor, MultiSelect, Password, PasswordDisplayMode, Select, Text};
use std::io::{Read, Write};
use std::process::Command;
use tempfile::NamedTempFile;
/// Prompt for text input
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `default` - Optional default value
/// * `placeholder` - Optional placeholder text
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let name = prompts::text("Enter name", None, None)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn text(prompt: &str, default: Option<&str>, placeholder: Option<&str>) -> Result<String> {
let mut text_prompt = Text::new(prompt);
if let Some(def) = default {
text_prompt = text_prompt.with_default(def);
}
if let Some(ph) = placeholder {
text_prompt = text_prompt.with_placeholder(ph);
}
match text_prompt.prompt() {
Ok(result) => Ok(result),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_text(prompt, default)
}
}
}
/// Prompt for confirmation (yes/no)
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `default` - Optional default value
/// * `formatter` - Optional custom formatter (format: "true_text|false_text", e.g., "sim|não")
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let confirmed = prompts::confirm("Continue?", None, None)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn confirm(prompt: &str, default: Option<bool>, _formatter: Option<&str>) -> Result<bool> {
let mut confirm_prompt = Confirm::new(prompt);
if let Some(def) = default {
confirm_prompt = confirm_prompt.with_default(def);
}
// Apply default formatter based on inquire's standard
// Custom formatter from CLI is handled in main.rs after getting the boolean result
confirm_prompt = confirm_prompt
.with_formatter(&|ans| match ans {
true => "yes".to_owned(),
false => "no".to_owned(),
});
match confirm_prompt.prompt() {
Ok(result) => Ok(result),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_confirm(prompt, default)
}
}
}
/// Prompt for password input (masked by default)
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `with_toggle` - Allow showing/hiding password with Ctrl+R
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let password = prompts::password("Enter password", false)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn password(prompt: &str, with_toggle: bool) -> Result<String> {
let mut password_prompt = Password::new(prompt)
.with_display_mode(PasswordDisplayMode::Masked);
if with_toggle {
password_prompt = password_prompt
.with_display_toggle_enabled()
.with_help_message("Press Ctrl+R to toggle password visibility");
}
match password_prompt.prompt() {
Ok(result) => Ok(result),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_password(prompt)
}
}
}
/// Prompt for single selection from a list
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `options` - List of options to choose from
/// * `page_size` - Optional number of options per page
/// * `vim_mode` - Enable vim mode navigation
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let choice = prompts::select(
/// "Choose role",
/// vec!["Admin".to_string(), "User".to_string()],
/// None,
/// false,
/// )?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn select(
prompt: &str,
options: Vec<String>,
page_size: Option<usize>,
vim_mode: bool,
) -> Result<String> {
let mut select = Select::new(prompt, options);
if let Some(size) = page_size {
select = select.with_page_size(size);
}
if vim_mode {
select = select.with_vim_mode(true);
}
match select.prompt() {
Ok(result) => Ok(result),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_select(prompt)
}
}
}
/// Prompt for multiple selections from a list
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `options` - List of options to choose from
/// * `page_size` - Optional number of options per page
/// * `vim_mode` - Enable vim mode navigation
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let choices = prompts::multi_select(
/// "Choose services",
/// vec!["nginx".to_string(), "postgres".to_string()],
/// None,
/// false,
/// )?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn multi_select(
prompt: &str,
options: Vec<String>,
page_size: Option<usize>,
vim_mode: bool,
) -> Result<Vec<String>> {
let mut multi_select = MultiSelect::new(prompt, options);
if let Some(size) = page_size {
multi_select = multi_select.with_page_size(size);
}
if vim_mode {
multi_select = multi_select.with_vim_mode(true);
}
match multi_select.prompt() {
Ok(results) => Ok(results),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_multi_select(prompt)
}
}
}
/// Prompt for text using an external editor
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `file_extension` - Optional file extension for the temp file (for syntax highlighting)
/// * `default_content` - Optional default content (text or loaded from file)
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// // With literal text template
/// let text = prompts::editor("Enter description", Some("md"), Some("# Title\n\n## Section\n"))?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn editor(
prompt: &str,
file_extension: Option<&str>,
prefix_text: Option<&str>,
) -> Result<String> {
let mut editor = InquireEditor::new(prompt);
if let Some(ext) = file_extension {
editor = editor.with_file_extension(ext);
}
if let Some(text) = prefix_text {
editor = editor.with_predefined_text(text);
}
// Formatter to show truncated preview of submitted text
editor = editor.with_formatter(&|text| {
if text.is_empty() {
"<skipped>".to_string()
} else if text.len() <= 50 {
text.to_string()
} else {
format!("{}...", &text[..47])
}
});
match editor.prompt() {
Ok(result) => Ok(result),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using fallback editor");
stdin_editor(prompt, prefix_text, file_extension)
}
}
}
/// Prompt for date selection using a calendar
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `default` - Optional default date (YYYY-MM-DD format)
/// * `min_date` - Optional minimum date (YYYY-MM-DD format)
/// * `max_date` - Optional maximum date (YYYY-MM-DD format)
/// * `week_start` - Week start day (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let date = prompts::date("Select date", None, None, None, "Mon")?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn date(
prompt: &str,
default: Option<&str>,
min_date: Option<&str>,
max_date: Option<&str>,
week_start: &str,
) -> Result<String> {
let mut date_select = DateSelect::new(prompt);
if let Some(def) = default {
if let Ok(parsed) = NaiveDate::parse_from_str(def, "%Y-%m-%d") {
date_select = date_select.with_default(parsed);
}
}
if let Some(min) = min_date {
if let Ok(parsed) = NaiveDate::parse_from_str(min, "%Y-%m-%d") {
date_select = date_select.with_min_date(parsed);
}
}
if let Some(max) = max_date {
if let Ok(parsed) = NaiveDate::parse_from_str(max, "%Y-%m-%d") {
date_select = date_select.with_max_date(parsed);
}
}
// Parse week start day
let weekday = match week_start.to_lowercase().as_str() {
"mon" => Weekday::Mon,
"tue" => Weekday::Tue,
"wed" => Weekday::Wed,
"thu" => Weekday::Thu,
"fri" => Weekday::Fri,
"sat" => Weekday::Sat,
"sun" => Weekday::Sun,
_ => Weekday::Mon, // Default to Monday
};
date_select = date_select.with_week_start(weekday);
match date_select.prompt() {
Ok(result) => Ok(result.format("%Y-%m-%d").to_string()),
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_date(prompt, default)
}
}
}
/// Prompt for custom type with validation
///
/// # Arguments
///
/// * `prompt` - The prompt message
/// * `type_name` - Type name (i32, u16, f64, ipv4, ipv6, etc.)
/// * `default` - Optional default value
///
/// # Example
///
/// ```no_run
/// # use typedialog_core::prompts;
/// let port = prompts::custom("Enter port", "u16", None)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn custom(prompt: &str, type_name: &str, default: Option<&str>) -> Result<String> {
let validator = |input: &str| match type_name {
"i32" => input.parse::<i32>().is_ok(),
"i64" => input.parse::<i64>().is_ok(),
"u16" => input.parse::<u16>().is_ok(),
"u32" => input.parse::<u32>().is_ok(),
"u64" => input.parse::<u64>().is_ok(),
"f32" => input.parse::<f32>().is_ok(),
"f64" => input.parse::<f64>().is_ok(),
"ipv4" => input.parse::<std::net::Ipv4Addr>().is_ok(),
"ipv6" => input.parse::<std::net::Ipv6Addr>().is_ok(),
_ => true,
};
let placeholder_msg = format!("Expected type: {}", type_name);
let mut text_prompt = Text::new(prompt);
if let Some(def) = default {
text_prompt = text_prompt.with_default(def);
}
text_prompt = text_prompt.with_placeholder(&placeholder_msg);
match text_prompt.prompt() {
Ok(result) => {
if validator(&result) {
Ok(result)
} else {
Err(Error::validation_failed(format!(
"Invalid input for type {}",
type_name
)))
}
}
Err(_e) => {
eprintln!("Note: Interactive mode not available, using stdin input");
stdin_custom(prompt, type_name, default, &validator)
}
}
}
// ============================================================================
// Stdin Fallback Implementations
// ============================================================================
fn stdin_text(prompt: &str, default: Option<&str>) -> Result<String> {
println!("{}", prompt);
if let Some(def) = default {
print!("[{}]: ", def);
} else {
print!(": ");
}
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(default.unwrap_or("").to_string())
} else {
Ok(trimmed.to_string())
}
}
fn stdin_confirm(prompt: &str, default: Option<bool>) -> Result<bool> {
let default_str = match default {
Some(true) => "Y/n",
Some(false) => "y/N",
None => "y/n",
};
println!("{} [{}]: ", prompt, default_str);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
match input.trim().to_lowercase().as_str() {
"y" | "yes" | "true" => Ok(true),
"n" | "no" | "false" => Ok(false),
"" => Ok(default.unwrap_or(false)),
_ => Err(Error::validation_failed("Please answer yes or no")),
}
}
fn stdin_password(prompt: &str) -> Result<String> {
// Try rpassword first (requires real TTY), fall back to simple read if not available
let prompt_with_suffix = format!("{}: ", prompt);
match rpassword::prompt_password(&prompt_with_suffix) {
Ok(pwd) => Ok(pwd),
Err(_) => {
// Fallback for piped input (no TTY available)
// Show cursor at the beginning of line
println!("{}", prompt);
print!("> ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
}
}
fn stdin_select(prompt: &str) -> Result<String> {
println!("{}", prompt);
print!("Enter selection: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn stdin_multi_select(prompt: &str) -> Result<Vec<String>> {
println!("{}", prompt);
print!("Enter selections (comma-separated): ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let selections: Vec<String> = input
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(selections)
}
fn stdin_editor(prompt: &str, prefix_text: Option<&str>, file_extension: Option<&str>) -> Result<String> {
println!("{}", prompt);
// Try to open a temporary file with the editor
if let Ok(content) = open_editor_with_temp_file(prefix_text, file_extension) {
return Ok(content);
}
// Fallback to stdin-based editing if temp file approach fails
println!("\n[Unable to open editor, using stdin mode]");
println!("Paste your content below (Ctrl+D or Ctrl+Z to finish):");
// Start with prefix text if provided
let mut content = if let Some(text) = prefix_text {
text.replace("\\n", "\n")
} else {
String::new()
};
if !content.is_empty() {
println!("\nPre-filled content:");
println!("{}", content);
println!();
}
print!("> ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
// Combine prefix text with user input
content.push_str(&input);
Ok(content.trim_end().to_string())
}
/// Open a temporary file with the system editor
fn open_editor_with_temp_file(
prefix_text: Option<&str>,
file_extension: Option<&str>,
) -> Result<String> {
// Create temporary file with appropriate extension
let mut temp_file = if let Some(ext) = file_extension {
tempfile::Builder::new()
.suffix(&format!(".{}", ext))
.tempfile()
.map_err(Error::io)?
} else {
NamedTempFile::new().map_err(Error::io)?
};
// Write prefix text to temp file
if let Some(text) = prefix_text {
let content = text.replace("\\n", "\n");
temp_file.write_all(content.as_bytes()).map_err(Error::io)?;
temp_file.flush().map_err(Error::io)?;
}
let path = temp_file.path().to_path_buf();
// Get editor command from environment, with sensible defaults
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(windows) {
"notepad".to_string()
} else {
"nano".to_string()
}
});
// Open the file with the editor
let status = Command::new(&editor)
.arg(&path)
.status()
.map_err(Error::io)?;
if !status.success() {
return Err(Error::validation_failed(format!(
"Editor '{}' exited with error code",
editor
)));
}
// Read the edited content back
let content = std::fs::read_to_string(&path)
.map_err(Error::io)?;
Ok(content)
}
fn stdin_date(prompt: &str, default: Option<&str>) -> Result<String> {
println!("{}", prompt);
if let Some(def) = default {
print!("[{}] (YYYY-MM-DD): ", def);
} else {
print!("(YYYY-MM-DD): ");
}
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(default.unwrap_or("").to_string())
} else {
// Validate date format
NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")?;
Ok(trimmed.to_string())
}
}
fn stdin_custom<F>(
prompt: &str,
type_name: &str,
default: Option<&str>,
validator: &F,
) -> Result<String>
where
F: Fn(&str) -> bool,
{
loop {
println!("{}", prompt);
if let Some(def) = default {
print!("[{}]: ", def);
} else {
print!(": ");
}
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
if let Some(def) = default {
return Ok(def.to_string());
}
continue;
}
if validator(trimmed) {
return Ok(trimmed.to_string());
} else {
eprintln!("Invalid input for type {}", type_name);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stdlib_integration() {
// Just verify the types and signatures are correct
let _: Result<String> = Ok("test".to_string());
}
}

View File

@ -0,0 +1,111 @@
//! Template context builder for Tera templates
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
/// Builder for constructing Tera template context
pub struct TemplateContextBuilder {
context: tera::Context,
}
impl TemplateContextBuilder {
/// Create a new empty context builder
pub fn new() -> Self {
Self {
context: tera::Context::new(),
}
}
/// Add form results to the context
pub fn with_results(mut self, results: &HashMap<String, Value>) -> Self {
for (key, value) in results {
self.context.insert(key, value);
}
self
}
/// Add form definition metadata
pub fn with_form(mut self, form: &crate::FormDefinition) -> Self {
self.context.insert("form_name", &form.name);
if let Some(desc) = &form.description {
self.context.insert("form_description", desc);
}
self
}
/// Add environment variables
pub fn with_env(mut self) -> Self {
let mut env_vars = HashMap::new();
for (key, value) in std::env::vars() {
env_vars.insert(key, value);
}
self.context.insert("env", &env_vars);
self
}
/// Add custom data with any serializable type
pub fn with_data<T: Serialize>(mut self, key: &str, value: &T) -> Self {
if let Ok(json_value) = serde_json::to_value(value) {
self.context.insert(key, &json_value);
}
self
}
/// Add a timestamp (ISO 8601 format)
pub fn with_timestamp(mut self) -> Self {
let now = chrono::Utc::now().to_rfc3339();
self.context.insert("now", &now);
self
}
/// Build and return the context
pub fn build(self) -> tera::Context {
self.context
}
}
impl Default for TemplateContextBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_context_builder_new() {
let _context = TemplateContextBuilder::new().build();
// Context created successfully
}
#[test]
fn test_context_builder_with_data() {
let _context = TemplateContextBuilder::new()
.with_data("name", &"test".to_string())
.build();
// Context with data created successfully
}
#[test]
fn test_context_builder_with_env() {
let _context = TemplateContextBuilder::new().with_env().build();
// Context with env created successfully
}
#[test]
fn test_context_builder_chaining() {
let mut results = HashMap::new();
results.insert("username".to_string(), json!("alice"));
let _context = TemplateContextBuilder::new()
.with_results(&results)
.with_env()
.with_timestamp()
.build();
// Chained context created successfully
}
}

View File

@ -0,0 +1,93 @@
//! Custom Tera filters for templates
use std::collections::HashMap;
use tera::{Filter, Result as TeraResult, Value};
/// Filter for formatting dates
///
/// Usage: `{{ "2024-12-13" | date(format="%d/%m/%Y") }}`
pub struct DateFormatFilter;
impl Filter for DateFormatFilter {
fn filter(&self, value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
let date_str = value
.as_str()
.ok_or_else(|| tera::Error::msg("Value must be a string"))?;
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%d");
match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(date) => Ok(Value::String(date.format(format).to_string())),
Err(_) => {
// If parsing fails, return the original value
Ok(value.clone())
}
}
}
}
/// Filter for JSON formatting
///
/// Usage: `{{ data | json }}`
pub struct JsonFilter;
impl Filter for JsonFilter {
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
serde_json::to_string_pretty(value)
.map(Value::String)
.map_err(|e| tera::Error::msg(format!("JSON error: {}", e)))
}
}
/// Filter for compact JSON formatting
///
/// Usage: `{{ data | json_compact }}`
pub struct JsonCompactFilter;
impl Filter for JsonCompactFilter {
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
serde_json::to_string(value)
.map(Value::String)
.map_err(|e| tera::Error::msg(format!("JSON error: {}", e)))
}
}
/// Filter for string uppercasing
///
/// Usage: `{{ text | upper }}`
pub struct UpperFilter;
impl Filter for UpperFilter {
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
value
.as_str()
.map(|s| Value::String(s.to_uppercase()))
.ok_or_else(|| tera::Error::msg("Value must be a string"))
}
}
/// Filter for string lowercasing
///
/// Usage: `{{ text | lower }}`
pub struct LowerFilter;
impl Filter for LowerFilter {
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
value
.as_str()
.map(|s| Value::String(s.to_lowercase()))
.ok_or_else(|| tera::Error::msg("Value must be a string"))
}
}
/// Register all custom filters with a Tera instance
pub fn register_filters(tera: &mut tera::Tera) {
tera.register_filter("date", DateFormatFilter);
tera.register_filter("json", JsonFilter);
tera.register_filter("json_compact", JsonCompactFilter);
tera.register_filter("upper", UpperFilter);
tera.register_filter("lower", LowerFilter);
}

View File

@ -0,0 +1,129 @@
//! Template engine support using Tera
mod context;
mod filters;
pub use context::TemplateContextBuilder;
use crate::error::{Error, Result};
use std::path::Path;
use tera::Tera;
/// Template engine wrapper around Tera
pub struct TemplateEngine {
tera: Tera,
}
impl TemplateEngine {
/// Create a new template engine
///
/// If `templates_path` is provided, loads templates from that directory.
/// Otherwise creates an engine for inline templates only.
pub fn new(templates_path: Option<&Path>) -> Result<Self> {
let mut tera = if let Some(path) = templates_path {
let pattern = path.join("**/*.tera");
let pattern_str = pattern.to_str().unwrap_or("templates/**/*.tera");
Tera::new(pattern_str).map_err(|e| {
Error::template_failed(format!("Failed to initialize Tera: {}", e))
})?
} else {
Tera::default()
};
// Register custom filters
filters::register_filters(&mut tera);
Ok(Self { tera })
}
/// Add a template string for inline template rendering
pub fn add_template(&mut self, name: &str, content: &str) -> Result<()> {
self.tera.add_raw_template(name, content).map_err(|e| {
Error::template_failed(format!("Failed to add template '{}': {}", name, e))
})
}
/// Render a template by name with context
pub fn render(&self, template_name: &str, context: &tera::Context) -> Result<String> {
self.tera.render(template_name, context).map_err(|e| {
Error::template_failed(format!("Failed to render template '{}': {}", template_name, e))
})
}
/// Render a template string directly
pub fn render_str(&self, template: &str, context: &tera::Context) -> Result<String> {
Tera::one_off(template, context, false)
.map_err(|e| Error::template_failed(format!("Failed to render template: {}", e)))
}
/// Check if a template exists
pub fn has_template(&self, name: &str) -> bool {
self.tera.get_template(name).is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn test_template_engine_new() {
let engine = TemplateEngine::new(None);
assert!(engine.is_ok());
}
#[test]
fn test_template_engine_render_str() {
let engine = TemplateEngine::new(None).unwrap();
let context = TemplateContextBuilder::new()
.with_data("name", &"Alice")
.build();
let result = engine.render_str("Hello {{ name }}!", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Hello Alice!");
}
#[test]
fn test_template_engine_add_and_render() {
let mut engine = TemplateEngine::new(None).unwrap();
let result = engine.add_template("greeting", "Hello {{ name }}!");
assert!(result.is_ok());
assert!(engine.has_template("greeting"));
}
#[test]
fn test_template_with_results() {
let engine = TemplateEngine::new(None).unwrap();
let mut results = HashMap::new();
results.insert("username".to_string(), json!("bob"));
results.insert("email".to_string(), json!("bob@example.com"));
let context = TemplateContextBuilder::new()
.with_results(&results)
.build();
let template = "User: {{ username }}, Email: {{ email }}";
let result = engine.render_str(template, &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "User: bob, Email: bob@example.com");
}
#[test]
fn test_template_with_filters() {
let engine = TemplateEngine::new(None).unwrap();
let context = TemplateContextBuilder::new()
.with_data("text", &"hello")
.build();
let result = engine.render_str("{{ text | upper }}", &context);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "HELLO");
}
}

View File

@ -0,0 +1,23 @@
[package]
name = "typedialog-tui"
version.workspace = true
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
description = "TypeDialog TUI tool for interactive forms using ratatui"
[[bin]]
name = "typedialog-tui"
path = "src/main.rs"
[dependencies]
typedialog-core = { path = "../typedialog-core", features = ["tui", "i18n"] }
clap = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
serde_json = { workspace = true }
unic-langid = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,93 @@
//! typedialog-tui - Terminal UI tool for interactive forms
//!
//! A terminal UI (TUI) tool for creating interactive forms with enhanced visual presentation.
//! Uses ratatui for advanced terminal rendering capabilities.
use clap::Parser;
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 std::path::PathBuf;
use std::fs;
use std::collections::HashMap;
use unic_langid::LanguageIdentifier;
#[derive(Parser)]
#[command(
name = "typedialog-tui",
version,
about = "Terminal UI tool for interactive forms",
long_about = cli_common::TUI_MAIN_LONG_ABOUT
)]
struct Args {
/// Path to TOML form configuration file
config: PathBuf,
/// Output format: json, yaml, toml, or text
#[arg(short, long, default_value = "text", help = cli_common::FORMAT_FLAG_HELP)]
format: String,
/// Output file (if not specified, writes to stdout)
#[arg(short, long, help = cli_common::OUT_FLAG_HELP)]
out: Option<PathBuf>,
/// Locale override for form localization
#[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)]
locale: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
execute_form(args.config, &args.format, &args.out, &args.locale).await
}
async fn execute_form(config: 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)?;
// Load I18nBundle if needed
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config.clone());
let form_locale = form.locale.as_deref();
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
let fallback_locale: LanguageIdentifier = "en-US".parse()
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
let loader = LocaleLoader::new(config.locales_path);
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
} else {
None
};
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?
} else {
form_parser::execute_with_backend(form, backend.as_mut()).await?
};
print_results(&results, format, output_file)?;
Ok(())
}
fn print_results(
results: &HashMap<String, serde_json::Value>,
format: &str,
output_file: &Option<PathBuf>,
) -> Result<()> {
let output = helpers::format_results(results, format)?;
if let Some(path) = output_file {
fs::write(path, &output).map_err(Error::io)?;
} else {
println!("{}", output);
}
Ok(())
}

View File

@ -0,0 +1,23 @@
[package]
name = "typedialog-web"
version.workspace = true
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
description = "TypeDialog Web server for interactive forms using axum"
[[bin]]
name = "typedialog-web"
path = "src/main.rs"
[dependencies]
typedialog-core = { path = "../typedialog-core", features = ["web", "i18n"] }
clap = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
serde_json = { workspace = true }
unic-langid = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,74 @@
//! typedialog-web - Web server for interactive forms
//!
//! 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 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 std::path::PathBuf;
use std::fs;
use unic_langid::LanguageIdentifier;
#[derive(Parser)]
#[command(
name = "typedialog-web",
version,
about = "Web server for interactive forms",
long_about = cli_common::WEB_MAIN_LONG_ABOUT
)]
struct Args {
/// Path to TOML form configuration file
config: PathBuf,
/// Port to listen on (can also be set via TYPEDIALOG_PORT env var)
#[arg(short, long, default_value = "8080")]
port: u16,
/// Locale override for form localization
#[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)]
locale: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
execute_form(args.config, args.port, &args.locale).await
}
async fn execute_form(config: 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)?;
// Load I18nBundle if needed
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config.clone());
let form_locale = form.locale.as_deref();
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
let fallback_locale: LanguageIdentifier = "en-US".parse()
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
let loader = LocaleLoader::new(config.locales_path);
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
} else {
None
};
let mut backend = BackendFactory::create(BackendType::Web { port })?;
println!("Starting typedialog web server for form: {}", form.name);
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?
} else {
form_parser::execute_with_backend(form, backend.as_mut()).await?
};
Ok(())
}

View File

@ -0,0 +1,24 @@
[package]
name = "typedialog"
version.workspace = true
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
description = "TypeDialog CLI tool for interactive forms and prompts"
[[bin]]
name = "typedialog"
path = "src/main.rs"
[dependencies]
typedialog-core = { path = "../typedialog-core", features = ["cli", "i18n"] }
clap = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
serde_json = { workspace = true }
toml = { workspace = true }
unic-langid = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,532 @@
//! typedialog - Interactive forms and prompts CLI tool
//!
//! A powerful CLI tool for creating interactive forms and prompts using multiple backends.
//! Works with piped input for batch processing and scripts.
use clap::{Parser, Subcommand};
use typedialog_core::{prompts, form_parser, Error, Result};
use typedialog_core::backends::BackendFactory;
use typedialog_core::helpers;
use typedialog_core::nickel::{NickelCli, MetadataParser, TomlGenerator, TemplateEngine};
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
use typedialog_core::config::TypeDialogConfig;
use typedialog_core::cli_common;
use std::path::PathBuf;
use std::fs;
use std::collections::HashMap;
use serde_json::json;
use unic_langid::LanguageIdentifier;
#[derive(Parser)]
#[command(
name = "typedialog",
version,
about = "Interactive forms and prompts CLI tool with multiple backend support",
long_about = cli_common::CLI_MAIN_LONG_ABOUT
)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Output format: json, yaml, toml, or text
#[arg(global = true, short, long, default_value = "text", help = cli_common::FORMAT_FLAG_HELP)]
format: String,
/// Output file (if not specified, writes to stdout)
#[arg(global = true, short, long, help = cli_common::OUT_FLAG_HELP)]
out: Option<PathBuf>,
/// Locale override for form localization
#[arg(global = true, short, long, help = cli_common::LOCALE_FLAG_HELP)]
locale: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
/// Prompt for text input
Text {
/// Prompt message
prompt: String,
/// Default value
#[arg(short, long)]
default: Option<String>,
/// Placeholder text
#[arg(short, long)]
placeholder: Option<String>,
},
/// Prompt for confirmation (yes/no)
Confirm {
/// Prompt message
prompt: String,
/// Default value (true/false)
#[arg(short, long)]
default: Option<bool>,
},
/// Select a single option from a list
Select {
/// Prompt message
prompt: String,
/// Options to choose from
options: Vec<String>,
/// Page size (number of options per page)
#[arg(short, long)]
page_size: Option<usize>,
/// Enable vim mode navigation
#[arg(long)]
vim_mode: bool,
},
/// Select multiple options from a list
#[command(name = "multi-select")]
MultiSelect {
/// Prompt message
prompt: String,
/// Options to choose from
options: Vec<String>,
/// Page size (number of options per page)
#[arg(short, long)]
page_size: Option<usize>,
/// Enable vim mode navigation
#[arg(long)]
vim_mode: bool,
},
/// Prompt for password input (masked)
Password {
/// Prompt message
prompt: String,
/// Allow showing/hiding password with Ctrl+R
#[arg(long)]
with_toggle: bool,
},
/// Prompt for custom typed input
Custom {
/// Prompt message
prompt: String,
/// Type name (i32, f64, ipv4, ipv6, uuid, etc.)
#[arg(short, long)]
type_name: String,
/// Default value
#[arg(short, long)]
default: Option<String>,
},
/// Prompt for text using an external editor
Editor {
/// Prompt message
prompt: String,
/// File extension for the temp file
#[arg(short, long)]
file_extension: Option<String>,
/// Default content (text or path to file to load)
#[arg(short, long)]
default: Option<String>,
},
/// Prompt for date selection using a calendar
Date {
/// Prompt message
prompt: String,
/// Default date (YYYY-MM-DD format)
#[arg(short, long)]
default: Option<String>,
/// Minimum date (YYYY-MM-DD format)
#[arg(long)]
min_date: Option<String>,
/// Maximum date (YYYY-MM-DD format)
#[arg(long)]
max_date: Option<String>,
/// Week start day (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
#[arg(long, default_value = "Mon")]
week_start: String,
},
/// Execute interactive form from TOML configuration
Form {
/// Path to TOML form configuration file
config: PathBuf,
/// Optional path to Nickel template (.ncl.j2) for direct Nickel generation
#[arg(value_name = "TEMPLATE")]
template: Option<PathBuf>,
},
/// Convert Nickel schema to TOML form
#[command(name = "nickel-to-form")]
NickelToForm {
/// Path to Nickel schema file (.ncl)
schema: PathBuf,
/// Optional path to current data file (.ncl or .json) for defaults
#[arg(value_name = "CURRENT_DATA")]
current_data: Option<PathBuf>,
/// Flatten nested records into flat field names
#[arg(long)]
flatten: bool,
/// Use semantic grouping for form organization
#[arg(long)]
groups: bool,
},
/// Convert form results to Nickel output
#[command(name = "form-to-nickel")]
FormToNickel {
/// Path to TOML form definition
form: PathBuf,
/// Path to results JSON file OR Nickel template (.ncl.j2)
/// - If .json: Read pre-computed results (3-step workflow)
/// - If .ncl.j2: Execute form + render template (2-step workflow)
input: PathBuf,
/// Validate output with nickel typecheck
#[arg(long)]
validate: bool,
},
/// Render Nickel template with form results
#[command(name = "nickel-template")]
NickelTemplate {
/// Path to Nickel template file (.ncl.j2)
template: PathBuf,
/// Path to results JSON file
results: PathBuf,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Text {
prompt,
default,
placeholder,
} => {
let result = prompts::text(&prompt, default.as_deref(), placeholder.as_deref())?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Confirm { prompt, default } => {
let result = prompts::confirm(&prompt, default, None)?;
print_result("value", &result.to_string(), &cli.format, &cli.out)?;
}
Commands::Select {
prompt,
options,
page_size,
vim_mode,
} => {
let result = prompts::select(&prompt, options, page_size, vim_mode)?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::MultiSelect {
prompt,
options,
page_size,
vim_mode,
} => {
let results = prompts::multi_select(&prompt, options, page_size, vim_mode)?;
let output = json!(results);
print_result("values", &output.to_string(), &cli.format, &cli.out)?;
}
Commands::Password {
prompt,
with_toggle,
} => {
let result = prompts::password(&prompt, with_toggle)?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Custom {
prompt,
type_name,
default,
} => {
let result = prompts::custom(&prompt, &type_name, default.as_deref())?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Editor {
prompt,
file_extension,
default,
} => {
let result = prompts::editor(&prompt, file_extension.as_deref(), default.as_deref())?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Date {
prompt,
default,
min_date,
max_date,
week_start,
} => {
let result = prompts::date(&prompt, default.as_deref(), min_date.as_deref(), max_date.as_deref(), &week_start)?;
print_result("value", &result, &cli.format, &cli.out)?;
}
Commands::Form { config, template } => {
execute_form(config, template, &cli.format, &cli.out, &cli.locale).await?;
}
Commands::NickelToForm {
schema,
current_data,
flatten,
groups,
} => {
nickel_to_form_cmd(schema, current_data, &cli.out, flatten, groups)?;
}
Commands::FormToNickel {
form,
input,
validate,
} => {
form_to_nickel_cmd(form, input, &cli.out, validate)?;
}
Commands::NickelTemplate {
template,
results,
} => {
nickel_template_cmd(template, results, &cli.out)?;
}
}
Ok(())
}
async fn execute_form(config: PathBuf, template: 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)?;
// 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
let config = TypeDialogConfig::default();
let resolver = LocaleResolver::new(config.clone());
let form_locale = form.locale.as_deref();
// resolve() already returns a LanguageIdentifier
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
let fallback_locale: LanguageIdentifier = "en-US".parse()
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
// Load translations
let loader = LocaleLoader::new(config.locales_path);
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
} else {
None
};
// Auto-detect backend from TYPEDIALOG_BACKEND env var (tui/web/cli, default cli)
let backend_type = BackendFactory::auto_detect();
let mut backend = BackendFactory::create(backend_type)?;
// Execute form with i18n support
let results = if let Some(ref bundle) = i18n_bundle {
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).await?
} else {
form_parser::execute_with_backend(form, backend.as_mut()).await?
};
// If template provided, generate Nickel output directly
if let Some(template_path) = template {
// Load and render template with form results
let mut engine = TemplateEngine::new();
let nickel_output = engine.render_file(template_path.as_path(), &results)?;
// Write output
if let Some(path) = output_file {
fs::write(path, &nickel_output).map_err(Error::io)?;
} else {
println!("{}", nickel_output);
}
} else {
// No template: return results in requested format (json, yaml, text)
print_results(&results, format, output_file)?;
}
Ok(())
}
fn print_result(
key: &str,
value: &str,
format: &str,
output_file: &Option<PathBuf>,
) -> Result<()> {
let output = match format {
"json" => {
let mut map = HashMap::new();
map.insert(key, value);
serde_json::to_string_pretty(&map).unwrap_or_default()
}
"yaml" => {
format!("{}: {}", key, value)
}
"toml" => {
format!("{} = \"{}\"", key, value.escape_default())
}
_ => value.to_string(),
};
if let Some(path) = output_file {
fs::write(path, &output).map_err(Error::io)?;
} else {
println!("{}", output);
}
Ok(())
}
fn print_results(
results: &HashMap<String, serde_json::Value>,
format: &str,
output_file: &Option<PathBuf>,
) -> Result<()> {
let output = helpers::format_results(results, format)?;
if let Some(path) = output_file {
fs::write(path, &output).map_err(Error::io)?;
} else {
println!("{}", output);
}
Ok(())
}
fn nickel_to_form_cmd(
schema: PathBuf,
_current_data: Option<PathBuf>,
output: &Option<PathBuf>,
flatten: bool,
groups: bool,
) -> Result<()> {
// Verify nickel CLI is available
NickelCli::verify()?;
// Extract metadata from schema
let metadata = NickelCli::query(schema.as_path(), Some("inputs"))?;
// Parse into intermediate representation
let schema_ir = MetadataParser::parse(metadata)?;
// Generate TOML form
let form_def = TomlGenerator::generate(&schema_ir, flatten, groups)?;
// 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());
} else {
println!("{}", toml_output);
}
Ok(())
}
fn form_to_nickel_cmd(
form: PathBuf,
input: PathBuf,
output: &Option<PathBuf>,
_validate: bool,
) -> Result<()> {
let form_content = fs::read_to_string(&form).map_err(Error::io)?;
let _form_def = form_parser::parse_toml(&form_content)?;
// Determine input type based on extension
let results: HashMap<String, serde_json::Value> = if input.extension().and_then(|s| s.to_str()) == Some("ncl.j2") {
// Template: would require executing form and rendering template
// For now, return error as this requires interactive execution
return Err(Error::validation_failed(
"Template-based form-to-nickel requires interactive execution. Use .json input instead."
));
} else if input.extension().and_then(|s| s.to_str()) == Some("json") {
// Load pre-computed results from JSON
let json_content = fs::read_to_string(&input).map_err(Error::io)?;
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?
} else {
return Err(Error::validation_failed(
"Input file must be .json or .ncl.j2"
));
};
// For now, provide a placeholder message as full Nickel serialization requires schema
let nickel_output = format!("# Form results (JSON format for now)\n{}",
serde_json::to_string_pretty(&results)
.map_err(|e| Error::validation_failed(e.to_string()))?);
// Write output
if let Some(path) = output {
fs::write(path, &nickel_output).map_err(Error::io)?;
println!("Nickel output written to {}", path.display());
} else {
println!("{}", nickel_output);
}
Ok(())
}
fn nickel_template_cmd(
template: PathBuf,
results: PathBuf,
output: &Option<PathBuf>,
) -> Result<()> {
// Load results JSON file
let json_content = fs::read_to_string(&results).map_err(Error::io)?;
let values: HashMap<String, serde_json::Value> =
serde_json::from_str(&json_content)
.map_err(|e| Error::validation_failed(e.to_string()))?;
// Load and render template
let mut engine = TemplateEngine::new();
let nickel_output = engine.render_file(template.as_path(), &values)?;
// Write output
if let Some(path) = output {
fs::write(path, &nickel_output).map_err(Error::io)?;
println!("Template rendered to {}", path.display());
} else {
println!("{}", nickel_output);
}
Ok(())
}