From 5a459e7f02bb35da1fbb6d756143be89e5ae80c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Thu, 18 Dec 2025 01:16:44 +0000 Subject: [PATCH] chore: add crates --- Cargo.toml | 74 + crates/typedialog-core/Cargo.toml | 72 + crates/typedialog-core/src/autocompletion.rs | 273 ++ crates/typedialog-core/src/backends/cli.rs | 201 ++ crates/typedialog-core/src/backends/mod.rs | 184 ++ crates/typedialog-core/src/backends/tui.rs | 2254 +++++++++++++++++ .../typedialog-core/src/backends/web/mod.rs | 1228 +++++++++ crates/typedialog-core/src/cli_common.rs | 155 ++ crates/typedialog-core/src/config/loader.rs | 46 + crates/typedialog-core/src/config/mod.rs | 68 + crates/typedialog-core/src/error.rs | 182 ++ crates/typedialog-core/src/form_parser.rs | 1292 ++++++++++ crates/typedialog-core/src/helpers.rs | 128 + crates/typedialog-core/src/i18n/loader.rs | 158 ++ crates/typedialog-core/src/i18n/mod.rs | 130 + crates/typedialog-core/src/i18n/resolver.rs | 134 + crates/typedialog-core/src/lib.rs | 116 + crates/typedialog-core/src/nickel/cli.rs | 239 ++ .../typedialog-core/src/nickel/contracts.rs | 323 +++ crates/typedialog-core/src/nickel/mod.rs | 46 + crates/typedialog-core/src/nickel/parser.rs | 254 ++ .../typedialog-core/src/nickel/schema_ir.rs | 108 + .../typedialog-core/src/nickel/serializer.rs | 356 +++ .../src/nickel/template_engine.rs | 207 ++ .../src/nickel/toml_generator.rs | 413 +++ crates/typedialog-core/src/nickel/types.rs | 16 + crates/typedialog-core/src/prompts.rs | 639 +++++ .../typedialog-core/src/templates/context.rs | 111 + .../typedialog-core/src/templates/filters.rs | 93 + crates/typedialog-core/src/templates/mod.rs | 129 + crates/typedialog-tui/Cargo.toml | 23 + crates/typedialog-tui/src/main.rs | 93 + crates/typedialog-web/Cargo.toml | 23 + crates/typedialog-web/src/main.rs | 74 + crates/typedialog/Cargo.toml | 24 + crates/typedialog/src/main.rs | 532 ++++ 36 files changed, 10398 insertions(+) create mode 100644 Cargo.toml create mode 100644 crates/typedialog-core/Cargo.toml create mode 100644 crates/typedialog-core/src/autocompletion.rs create mode 100644 crates/typedialog-core/src/backends/cli.rs create mode 100644 crates/typedialog-core/src/backends/mod.rs create mode 100644 crates/typedialog-core/src/backends/tui.rs create mode 100644 crates/typedialog-core/src/backends/web/mod.rs create mode 100644 crates/typedialog-core/src/cli_common.rs create mode 100644 crates/typedialog-core/src/config/loader.rs create mode 100644 crates/typedialog-core/src/config/mod.rs create mode 100644 crates/typedialog-core/src/error.rs create mode 100644 crates/typedialog-core/src/form_parser.rs create mode 100644 crates/typedialog-core/src/helpers.rs create mode 100644 crates/typedialog-core/src/i18n/loader.rs create mode 100644 crates/typedialog-core/src/i18n/mod.rs create mode 100644 crates/typedialog-core/src/i18n/resolver.rs create mode 100644 crates/typedialog-core/src/lib.rs create mode 100644 crates/typedialog-core/src/nickel/cli.rs create mode 100644 crates/typedialog-core/src/nickel/contracts.rs create mode 100644 crates/typedialog-core/src/nickel/mod.rs create mode 100644 crates/typedialog-core/src/nickel/parser.rs create mode 100644 crates/typedialog-core/src/nickel/schema_ir.rs create mode 100644 crates/typedialog-core/src/nickel/serializer.rs create mode 100644 crates/typedialog-core/src/nickel/template_engine.rs create mode 100644 crates/typedialog-core/src/nickel/toml_generator.rs create mode 100644 crates/typedialog-core/src/nickel/types.rs create mode 100644 crates/typedialog-core/src/prompts.rs create mode 100644 crates/typedialog-core/src/templates/context.rs create mode 100644 crates/typedialog-core/src/templates/filters.rs create mode 100644 crates/typedialog-core/src/templates/mod.rs create mode 100644 crates/typedialog-tui/Cargo.toml create mode 100644 crates/typedialog-tui/src/main.rs create mode 100644 crates/typedialog-web/Cargo.toml create mode 100644 crates/typedialog-web/src/main.rs create mode 100644 crates/typedialog/Cargo.toml create mode 100644 crates/typedialog/src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d19fc54 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" diff --git a/crates/typedialog-core/Cargo.toml b/crates/typedialog-core/Cargo.toml new file mode 100644 index 0000000..5e14bbb --- /dev/null +++ b/crates/typedialog-core/Cargo.toml @@ -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 diff --git a/crates/typedialog-core/src/autocompletion.rs b/crates/typedialog-core/src/autocompletion.rs new file mode 100644 index 0000000..119caeb --- /dev/null +++ b/crates/typedialog-core/src/autocompletion.rs @@ -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, + 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 { + 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 { + 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 { + 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, +} + +impl FilterCompleter { + /// Create a new filter completer + pub fn new(options: Vec) -> Self { + Self { options } + } + + /// Get filtered options matching the partial input + pub fn filter(&self, partial: &str) -> Vec { + 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 { + 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, +} + +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 { + 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 { + 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())); + } +} diff --git a/crates/typedialog-core/src/backends/cli.rs b/crates/typedialog-core/src/backends/cli.rs new file mode 100644 index 0000000..6d37ba4 --- /dev/null +++ b/crates/typedialog-core/src/backends/cli.rs @@ -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 { + 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 { + // 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()); + } +} diff --git a/crates/typedialog-core/src/backends/mod.rs b/crates/typedialog-core/src/backends/mod.rs new file mode 100644 index 0000000..71881fe --- /dev/null +++ b/crates/typedialog-core/src/backends/mod.rs @@ -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, + /// Optional locale override + pub locale: Option, +} + +/// 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; + + /// 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> { + // 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> { + 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::().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); + } +} diff --git a/crates/typedialog-core/src/backends/tui.rs b/crates/typedialog-core/src/backends/tui.rs new file mode 100644 index 0000000..0c47e5f --- /dev/null +++ b/crates/typedialog-core/src/backends/tui.rs @@ -0,0 +1,2254 @@ +//! TUI Backend using ratatui for terminal UI forms +//! +//! Complete ratatui-based terminal UI implementation with full support +//! for all 8 field types, custom widgets, and graceful terminal cleanup. +//! +//! Architecture: State separation (R-STATE-SEPARATION) with event loops +//! for each field type. Terminal lifecycle managed with RAII pattern. + +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::io; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use super::{FormBackend, RenderContext}; +use crate::error::{Error, Result}; +use crate::form_parser::{DisplayItem, FieldDefinition, FieldType}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::Alignment, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph}, + Terminal, +}; + +/// RAII Guard for terminal cleanup (R-GRACEFUL-SHUTDOWN pattern) +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + io::stdout(), + LeaveAlternateScreen, + event::DisableMouseCapture + ); + } +} + +/// Panel focus in form view +#[derive(Debug, Clone, Copy, PartialEq)] +enum FormPanel { + FieldList, + InputField, + Buttons, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ButtonFocus { + Cancel, + Submit, +} + +/// TUI Backend implementation using ratatui +/// +/// Full ratatui implementation with custom widget state machines +/// for each field type following R-STATE-SEPARATION guideline. +pub struct RatatuiBackend { + terminal: Arc>>>>, + _guard: Option, +} + +impl RatatuiBackend { + /// Create a new TUI backend instance + pub fn new() -> Self { + RatatuiBackend { + terminal: Arc::new(RwLock::new(None)), + _guard: None, + } + } +} + +impl Default for RatatuiBackend { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl FormBackend for RatatuiBackend { + /// Initialize terminal in raw mode with alternate screen buffer + /// Implements R-GRACEFUL-SHUTDOWN pattern with TerminalGuard + async fn initialize(&mut self) -> Result<()> { + // Enable raw mode + enable_raw_mode() + .map_err(|e| Error::validation_failed(format!("Failed to enable raw mode: {}", e)))?; + + // Enter alternate screen and enable mouse capture + execute!( + io::stdout(), + EnterAlternateScreen, + event::EnableMouseCapture + ) + .map_err(|e| { + Error::validation_failed(format!("Failed to enter alternate screen: {}", e)) + })?; + + // Create terminal backend + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend) + .map_err(|e| Error::validation_failed(format!("Failed to create terminal: {}", e)))?; + + *self.terminal.write().unwrap() = Some(terminal); + self._guard = Some(TerminalGuard); + + Ok(()) + } + + /// Render a display item with borders, margins, and conditional logic + /// Implements R-RESPONSIVE-LAYOUT with ratatui constraints and custom borders + async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> { + // Check conditional rendering + if let Some(condition) = &item.when { + if !evaluate_condition(condition, &context.results) { + return Ok(()); + } + } + + // Render display item with custom border support + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + terminal + .draw(|frame| { + let area = frame.area(); + + // Render custom borders if specified + if item.border_top.unwrap_or(false) { + render_top_border(frame, area, item); + } + + // Render title/content in center + let mut block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded); + + if let Some(title) = &item.title { + block = block.title(title.as_str()); + } + + let text = item.content.as_deref().unwrap_or(""); + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, area); + + // Render bottom border if specified + if item.border_bottom.unwrap_or(false) { + render_bottom_border(frame, area, item); + } + }) + .map_err(|e| Error::validation_failed(format!("Failed to render: {}", e)))?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + Ok(()) + } + + /// Execute a field - dispatches to appropriate field type handler + async fn execute_field( + &self, + field: &FieldDefinition, + _context: &RenderContext, + ) -> Result { + match field.field_type { + FieldType::Text => self.execute_text_field(field).await, + FieldType::Confirm => self.execute_confirm_field(field).await, + FieldType::Password => self.execute_password_field(field).await, + FieldType::Select => self.execute_select_field(field).await, + FieldType::MultiSelect => self.execute_multiselect_field(field).await, + FieldType::Custom => self.execute_custom_field(field).await, + FieldType::Editor => self.execute_editor_field(field).await, + FieldType::Date => self.execute_date_field(field).await, + } + } + + /// Execute complete form with left field list, right input, bottom buttons + async fn execute_form_complete( + &mut self, + form: &crate::form_parser::FormDefinition, + items: &[DisplayItem], + fields: &[FieldDefinition], + ) -> Result> { + let mut results = std::collections::HashMap::new(); + + // Render display items first + for item in items { + self.render_display_item( + item, + &super::RenderContext { + results: results.clone(), + locale: form.locale.clone(), + }, + ) + .await?; + } + + let mut selected_index = 0; + let mut focus_panel = FormPanel::FieldList; + let mut button_focus = ButtonFocus::Submit; + let mut field_buffer = String::new(); + + // Set selected_index to first visible field + let visible_indices = get_visible_field_indices(fields, &results); + if !visible_indices.is_empty() { + selected_index = visible_indices[0]; + } + + // Load initial buffer value for first field + load_field_buffer(&mut field_buffer, &fields[selected_index], &results); + + loop { + // Render form with 3-panel layout + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let fields_clone = fields.to_vec(); + let results_clone = results.clone(); + let selected_index_clone = selected_index; + let focus_clone = focus_panel; + let button_clone = button_focus; + let buffer_clone = field_buffer.clone(); + + terminal.draw(|frame| { + render_form_layout( + frame, + &fields_clone, + &results_clone, + selected_index_clone, + focus_clone, + button_clone, + &buffer_clone, + ); + })?; + } + } + + // Handle key input based on focus panel + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + // Global hotkeys (work in any panel) + match key.code { + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Exit and submit form with CTRL+E + if focus_panel == FormPanel::InputField { + let field = &fields[selected_index]; + let current_field_type = field.field_type; + + if !field_buffer.is_empty() { + if current_field_type == FieldType::Confirm { + let bool_value = field_buffer == "true"; + results.insert(field.name.clone(), Value::Bool(bool_value)); + } else { + results.insert( + field.name.clone(), + Value::String(field_buffer.clone()), + ); + } + } + } + // Finalize results with all fields and defaults + finalize_results(&mut results, fields); + return Ok(results); + } + KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Cancel form + return Err(Error::cancelled()); + } + _ => {} + } + + match focus_panel { + FormPanel::FieldList => match key.code { + KeyCode::Up => { + // Navigate to previous visible field + let visible_indices = get_visible_field_indices(fields, &results); + if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { + if current_pos > 0 { + selected_index = visible_indices[current_pos - 1]; + load_field_buffer( + &mut field_buffer, + &fields[selected_index], + &results, + ); + } + } + } + KeyCode::Down => { + // Navigate to next visible field + let visible_indices = get_visible_field_indices(fields, &results); + if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { + if current_pos < visible_indices.len() - 1 { + selected_index = visible_indices[current_pos + 1]; + load_field_buffer( + &mut field_buffer, + &fields[selected_index], + &results, + ); + } + } + } + KeyCode::Tab | KeyCode::Right => { + focus_panel = FormPanel::InputField; + } + KeyCode::Enter => { + focus_panel = FormPanel::InputField; + } + _ => {} + }, + FormPanel::InputField => { + let current_field_type = fields[selected_index].field_type; + + // Handle Select/MultiSelect fields with arrow navigation + if current_field_type == FieldType::Select + || current_field_type == FieldType::MultiSelect + { + if let Some(options) = &fields[selected_index].options { + match key.code { + KeyCode::Up => { + // Navigate to previous option + let current_idx = options + .iter() + .position(|o| o == &field_buffer) + .unwrap_or(0); + if current_idx > 0 { + field_buffer = options[current_idx - 1].clone(); + } + } + KeyCode::Down => { + // Navigate to next option + let current_idx = options + .iter() + .position(|o| o == &field_buffer) + .unwrap_or(0); + if current_idx < options.len() - 1 { + field_buffer = options[current_idx + 1].clone(); + } + } + KeyCode::Char(c) => { + // Search by first letter + let search_char = c.to_lowercase().to_string(); + if let Some(found) = options.iter().find(|o| { + o.to_lowercase().starts_with(&search_char) + }) { + field_buffer = found.clone(); + } + } + KeyCode::Tab | KeyCode::Right => { + focus_panel = FormPanel::Buttons; + button_focus = ButtonFocus::Submit; + } + KeyCode::Left => { + focus_panel = FormPanel::FieldList; + } + KeyCode::Enter => { + // Save field value + let field = &fields[selected_index]; + results.insert( + field.name.clone(), + Value::String(field_buffer.clone()), + ); + focus_panel = FormPanel::FieldList; + } + KeyCode::Esc => { + // Discard changes, reload from results + let field = &fields[selected_index]; + load_field_buffer(&mut field_buffer, field, &results); + focus_panel = FormPanel::FieldList; + } + _ => {} + } + } + } else if current_field_type == FieldType::Password { + // Password field input (same as text but characters are masked in display) + match key.code { + KeyCode::Char(c) => { + field_buffer.push(c); + } + KeyCode::Backspace => { + field_buffer.pop(); + } + KeyCode::Tab | KeyCode::Right => { + focus_panel = FormPanel::Buttons; + button_focus = ButtonFocus::Submit; + } + KeyCode::Left => { + focus_panel = FormPanel::FieldList; + } + KeyCode::Enter => { + // Save field value + let field = &fields[selected_index]; + results.insert( + field.name.clone(), + Value::String(field_buffer.clone()), + ); + focus_panel = FormPanel::FieldList; + } + KeyCode::Esc => { + // Discard changes, reload from results + let field = &fields[selected_index]; + load_field_buffer(&mut field_buffer, field, &results); + focus_panel = FormPanel::FieldList; + } + _ => {} + } + } else if current_field_type == FieldType::Confirm { + // Handle Confirm (boolean) field + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + field_buffer = "true".to_string(); + } + KeyCode::Char('n') | KeyCode::Char('N') => { + field_buffer = "false".to_string(); + } + KeyCode::Char(' ') => { + // Toggle on space + field_buffer = if field_buffer == "true" { + "false".to_string() + } else { + "true".to_string() + }; + } + KeyCode::Tab | KeyCode::Right => { + focus_panel = FormPanel::Buttons; + button_focus = ButtonFocus::Submit; + } + KeyCode::Left => { + focus_panel = FormPanel::FieldList; + } + KeyCode::Enter => { + // Save field value as boolean + let field = &fields[selected_index]; + let bool_value = field_buffer == "true"; + results.insert( + field.name.clone(), + Value::Bool(bool_value), + ); + focus_panel = FormPanel::FieldList; + } + KeyCode::Esc => { + // Discard changes, reload from results + let field = &fields[selected_index]; + load_field_buffer(&mut field_buffer, field, &results); + focus_panel = FormPanel::FieldList; + } + _ => {} + } + } else { + // Text and other field types + match key.code { + KeyCode::Char(c) => { + field_buffer.push(c); + } + KeyCode::Backspace => { + field_buffer.pop(); + } + KeyCode::Tab | KeyCode::Right => { + focus_panel = FormPanel::Buttons; + button_focus = ButtonFocus::Submit; + } + KeyCode::Left => { + focus_panel = FormPanel::FieldList; + } + KeyCode::Enter => { + // Save field value + let field = &fields[selected_index]; + results.insert( + field.name.clone(), + Value::String(field_buffer.clone()), + ); + focus_panel = FormPanel::FieldList; + } + KeyCode::Esc => { + // Discard changes, reload from results + let field = &fields[selected_index]; + load_field_buffer(&mut field_buffer, field, &results); + focus_panel = FormPanel::FieldList; + } + _ => {} + } + } + } + FormPanel::Buttons => match key.code { + KeyCode::Left | KeyCode::Tab => { + button_focus = if button_focus == ButtonFocus::Submit { + ButtonFocus::Cancel + } else { + ButtonFocus::Submit + }; + } + KeyCode::Right => { + button_focus = if button_focus == ButtonFocus::Cancel { + ButtonFocus::Submit + } else { + ButtonFocus::Cancel + }; + } + KeyCode::Up => { + focus_panel = FormPanel::InputField; + } + KeyCode::Enter => match button_focus { + ButtonFocus::Submit => { + finalize_results(&mut results, fields); + return Ok(results); + } + ButtonFocus::Cancel => return Err(Error::cancelled()), + }, + _ => {} + }, + } + } + } + } + } + + /// Shutdown terminal - cleanup happens via Drop trait on _guard + async fn shutdown(&mut self) -> Result<()> { + *self.terminal.write().unwrap() = None; + self._guard = None; + Ok(()) + } + + fn is_available() -> bool { + atty::is(atty::Stream::Stdout) + } + + fn name(&self) -> &str { + "tui" + } +} + +impl RatatuiBackend { + /// Execute text input field with cursor and edit support + /// Implements R-STATE-SEPARATION: TextInputState owns buffer, separate render logic + async fn execute_text_field(&self, field: &FieldDefinition) -> Result { + let mut state = TextInputState { + buffer: String::new(), + cursor_pos: 0, + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let buffer_clone = state.buffer.clone(); + let prompt = field.prompt.clone(); + let placeholder = field.placeholder.as_ref().cloned().unwrap_or_default(); + + terminal + .draw(|frame| { + let area = frame.area(); + let display_text = if buffer_clone.is_empty() && !placeholder.is_empty() + { + format!("{}: {} ", prompt, placeholder) + } else if buffer_clone.is_empty() { + format!("{}: [_] ", prompt) + } else { + format!("{}: {} ", prompt, buffer_clone) + }; + + let paragraph = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Non-blocking event loop (R-EVENT-LOOP: 100ms poll) + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Char(c) => { + state.buffer.insert(state.cursor_pos, c); + state.cursor_pos += 1; + } + KeyCode::Backspace => { + if state.cursor_pos > 0 { + state.buffer.remove(state.cursor_pos - 1); + state.cursor_pos -= 1; + } + } + KeyCode::Delete => { + if state.cursor_pos < state.buffer.len() { + state.buffer.remove(state.cursor_pos); + } + } + KeyCode::Left => { + if state.cursor_pos > 0 { + state.cursor_pos -= 1; + } + } + KeyCode::Right => { + if state.cursor_pos < state.buffer.len() { + state.cursor_pos += 1; + } + } + KeyCode::Home => state.cursor_pos = 0, + KeyCode::End => state.cursor_pos = state.buffer.len(), + KeyCode::Enter => { + if field.required.unwrap_or(false) && state.buffer.is_empty() { + continue; + } + return Ok(json!(state.buffer.clone())); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute confirm (yes/no) field + /// Simple boolean state machine with space/arrow key toggling + async fn execute_confirm_field(&self, field: &FieldDefinition) -> Result { + let mut state = ConfirmState { + value: field + .default + .as_deref() + .map(|d| d == "true") + .unwrap_or(false), + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let value = state.value; + let prompt = field.prompt.clone(); + + terminal + .draw(|frame| { + let area = frame.area(); + let yes_style = if value { " ✓ YES " } else { " yes " }; + let no_style = if !value { " ✗ NO " } else { " no " }; + let display_text = format!("{}: {} / {}", prompt, yes_style, no_style); + + let paragraph = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + state.value = true; + } + KeyCode::Char('n') | KeyCode::Char('N') => { + state.value = false; + } + KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right => { + state.value = !state.value; + } + KeyCode::Enter => return Ok(json!(state.value)), + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute password input field with optional visibility toggle + /// Masks input with bullets, Ctrl+R toggles visibility + async fn execute_password_field(&self, field: &FieldDefinition) -> Result { + let mut state = PasswordInputState { + buffer: String::new(), + visible: false, + with_toggle: field.placeholder.as_deref() == Some("toggle"), + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let buffer_clone = state.buffer.clone(); + let visible = state.visible; + let with_toggle = state.with_toggle; + let prompt = field.prompt.clone(); + + terminal + .draw(|frame| { + let area = frame.area(); + let display_value = if visible { + buffer_clone.clone() + } else { + "•".repeat(buffer_clone.len()) + }; + let display_text = if with_toggle && buffer_clone.is_empty() { + format!("{}: [_] (Ctrl+R to toggle)", prompt) + } else if with_toggle { + format!("{}: {} (Ctrl+R to toggle)", prompt, display_value) + } else { + format!("{}: {}", prompt, display_value) + }; + + let paragraph = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if state.with_toggle { + state.visible = !state.visible; + } + } + KeyCode::Char(c) => { + state.buffer.push(c); + } + KeyCode::Backspace => { + state.buffer.pop(); + } + KeyCode::Enter => { + if field.required.unwrap_or(false) && state.buffer.is_empty() { + continue; + } + return Ok(json!(state.buffer.clone())); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute select (single choice) field with pagination support + /// Implements pagination and navigation with Up/Down arrows + async fn execute_select_field(&self, field: &FieldDefinition) -> Result { + let options = field + .options + .as_ref() + .ok_or_else(|| Error::validation_failed("Select field must have options"))?; + + let page_size = field.page_size.unwrap_or(5); + let mut state = SelectState { + options: options.clone(), + selected_index: 0, + scroll_offset: 0, + page_size, + vim_mode: field.vim_mode.unwrap_or(false), + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let options_clone = state.options.clone(); + let selected_index = state.selected_index; + let scroll_offset = state.scroll_offset; + let page_size = state.page_size; + let prompt = field.prompt.clone(); + + terminal + .draw(|frame| { + let area = frame.area(); + + let mut display_lines = + vec![format!("{}: (Use arrow keys to navigate)", prompt)]; + for (i, option) in options_clone + .iter() + .enumerate() + .skip(scroll_offset) + .take(page_size) + { + let prefix = if i == selected_index { "▶ " } else { " " }; + let style = if i == selected_index { + " [SELECTED]" + } else { + "" + }; + display_lines.push(format!("{}{}{}", prefix, option, style)); + } + display_lines.push(format!( + "({}/{})", + selected_index + 1, + options_clone.len() + )); + + let text = display_lines.join("\n"); + let paragraph = + Paragraph::new(text).block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop with vim mode prep + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + let max_idx = state.options.len().saturating_sub(1); + match key.code { + KeyCode::Down => { + if state.selected_index < max_idx { + state.selected_index += 1; + if state.selected_index >= state.scroll_offset + state.page_size { + state.scroll_offset = + (state.selected_index - state.page_size + 1).max(0); + } + } + } + KeyCode::Up => { + if state.selected_index > 0 { + state.selected_index -= 1; + if state.selected_index < state.scroll_offset { + state.scroll_offset = state.selected_index; + } + } + } + KeyCode::Char('j') if state.vim_mode => { + if state.selected_index < max_idx { + state.selected_index += 1; + if state.selected_index >= state.scroll_offset + state.page_size { + state.scroll_offset = + (state.selected_index - state.page_size + 1).max(0); + } + } + } + KeyCode::Char('k') if state.vim_mode => { + if state.selected_index > 0 { + state.selected_index -= 1; + if state.selected_index < state.scroll_offset { + state.scroll_offset = state.selected_index; + } + } + } + KeyCode::Enter => { + return Ok(json!(state.options[state.selected_index].clone())); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute multiselect field with checkboxes + /// Allows selecting multiple options with Space, confirm with Enter + async fn execute_multiselect_field(&self, field: &FieldDefinition) -> Result { + let options = field + .options + .as_ref() + .ok_or_else(|| Error::validation_failed("MultiSelect field must have options"))?; + + let page_size = field.page_size.unwrap_or(5); + let mut state = MultiSelectState { + options: options.clone(), + selected: vec![false; options.len()], + cursor_index: 0, + scroll_offset: 0, + page_size, + vim_mode: field.vim_mode.unwrap_or(false), + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let options_clone = state.options.clone(); + let selected = state.selected.clone(); + let cursor_index = state.cursor_index; + let scroll_offset = state.scroll_offset; + let page_size = state.page_size; + let prompt = field.prompt.clone(); + + terminal + .draw(|frame| { + let area = frame.area(); + let mut display_lines = + vec![format!("{}: (Space to toggle, Enter to confirm)", prompt)]; + + for (i, option) in options_clone + .iter() + .enumerate() + .skip(scroll_offset) + .take(page_size) + { + let checkbox = if selected[i] { "[✓]" } else { "[ ]" }; + let prefix = if i == cursor_index { "▶ " } else { " " }; + display_lines.push(format!("{}{} {}", prefix, checkbox, option)); + } + + let selected_count = selected.iter().filter(|&&s| s).count(); + display_lines.push(format!( + "Selected: {}/{}", + selected_count, + options_clone.len() + )); + + let text = display_lines.join("\n"); + let paragraph = + Paragraph::new(text).block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + let max_idx = state.options.len().saturating_sub(1); + match key.code { + KeyCode::Down => { + if state.cursor_index < max_idx { + state.cursor_index += 1; + if state.cursor_index >= state.scroll_offset + state.page_size { + state.scroll_offset = + (state.cursor_index - state.page_size + 1).max(0); + } + } + } + KeyCode::Up => { + if state.cursor_index > 0 { + state.cursor_index -= 1; + if state.cursor_index < state.scroll_offset { + state.scroll_offset = state.cursor_index; + } + } + } + KeyCode::Char('j') if state.vim_mode => { + if state.cursor_index < max_idx { + state.cursor_index += 1; + if state.cursor_index >= state.scroll_offset + state.page_size { + state.scroll_offset = + (state.cursor_index - state.page_size + 1).max(0); + } + } + } + KeyCode::Char('k') if state.vim_mode => { + if state.cursor_index > 0 { + state.cursor_index -= 1; + if state.cursor_index < state.scroll_offset { + state.scroll_offset = state.cursor_index; + } + } + } + KeyCode::Char(' ') => { + state.selected[state.cursor_index] = + !state.selected[state.cursor_index]; + } + KeyCode::Enter => { + let selected_items: Vec = state + .options + .iter() + .enumerate() + .filter_map(|(i, opt)| { + if state.selected[i] { + Some(opt.clone()) + } else { + None + } + }) + .collect(); + return Ok(json!(selected_items)); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute custom type field with type-specific validation + /// Supports i32, f64, ipv4, ipv6, uuid, and other parseable types + async fn execute_custom_field(&self, field: &FieldDefinition) -> Result { + let custom_type = field.custom_type.as_deref().unwrap_or("String"); + let mut state = TextInputState { + buffer: String::new(), + cursor_pos: 0, + }; + + loop { + // Render current state with type hint + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let buffer_clone = state.buffer.clone(); + let prompt = field.prompt.clone(); + let placeholder = field.placeholder.clone().unwrap_or_default(); + + terminal + .draw(|frame| { + let area = frame.area(); + let display_text = if buffer_clone.is_empty() && !placeholder.is_empty() + { + format!("{} ({}): {} ", prompt, custom_type, placeholder) + } else if buffer_clone.is_empty() { + format!("{} ({}): [_] ", prompt, custom_type) + } else { + format!("{} ({}): {} ", prompt, custom_type, buffer_clone) + }; + + let paragraph = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| { + Error::validation_failed(format!("Failed to render: {}", e)) + })?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Char(c) => { + state.buffer.insert(state.cursor_pos, c); + state.cursor_pos += 1; + } + KeyCode::Backspace => { + if state.cursor_pos > 0 { + state.cursor_pos -= 1; + state.buffer.remove(state.cursor_pos); + } + } + KeyCode::Left => { + state.cursor_pos = state.cursor_pos.saturating_sub(1); + } + KeyCode::Right => { + if state.cursor_pos < state.buffer.len() { + state.cursor_pos += 1; + } + } + KeyCode::Home => state.cursor_pos = 0, + KeyCode::End => state.cursor_pos = state.buffer.len(), + KeyCode::Enter => { + // Validate based on custom type + let validation_result = + validate_custom_type(&state.buffer, custom_type); + if let Err(e) = validation_result { + // Show error overlay + self.show_validation_error(&format!( + "Invalid {}: {}", + custom_type, e + )) + .await?; + continue; + } + + // Return validated value (convert to appropriate type) + let value = parse_custom_value(&state.buffer, custom_type); + return Ok(value); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Display validation error overlay + async fn show_validation_error(&self, message: &str) -> Result<()> { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let error_msg = message.to_string(); + terminal + .draw(|frame| { + let area = frame.area(); + let error_text = format!("Error: {}", error_msg); + let paragraph = + Paragraph::new(error_text).block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| Error::validation_failed(format!("Failed to render error: {}", e)))?; + + // Wait for any key press + loop { + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(_) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + break; + } + } + } + } + Ok(()) + } + + /// Execute editor field with external $EDITOR support + /// Temporarily exits alternate screen to spawn editor, then returns content + async fn execute_editor_field(&self, field: &FieldDefinition) -> Result { + use std::fs; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + loop { + // Disable raw mode and exit alternate screen temporarily + disable_raw_mode().map_err(|e| { + Error::validation_failed(format!("Failed to disable raw mode: {}", e)) + })?; + execute!( + io::stdout(), + LeaveAlternateScreen, + event::DisableMouseCapture + ) + .map_err(|e| { + Error::validation_failed(format!("Failed to exit alternate screen: {}", e)) + })?; + + // Create temporary file with timestamp-based name + let file_ext = field.file_extension.as_deref().unwrap_or("txt"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temp_file = format!("/tmp/typedialog-{}.{}", timestamp, file_ext); + + // Write prefix text if present + if let Some(prefix) = &field.prefix_text { + fs::write(&temp_file, prefix).map_err(|e| { + Error::validation_failed(format!("Failed to write temp file: {}", e)) + })?; + } else { + fs::write(&temp_file, "").map_err(|e| { + Error::validation_failed(format!("Failed to create temp file: {}", e)) + })?; + } + + // Determine editor command + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + + // Launch editor + let status = Command::new(&editor) + .arg(&temp_file) + .status() + .map_err(|e| Error::validation_failed(format!("Failed to launch editor: {}", e)))?; + + if !status.success() { + let _ = fs::remove_file(&temp_file); + // Re-enable terminal + let _ = enable_raw_mode(); + let _ = execute!( + io::stdout(), + EnterAlternateScreen, + event::EnableMouseCapture + ); + return Err(Error::cancelled()); + } + + // Read content from temp file + let content = fs::read_to_string(&temp_file).map_err(|e| { + Error::validation_failed(format!("Failed to read temp file: {}", e)) + })?; + + // Clean up + let _ = fs::remove_file(&temp_file); + + // Re-enable raw mode and alternate screen + enable_raw_mode().map_err(|e| { + Error::validation_failed(format!("Failed to enable raw mode: {}", e)) + })?; + execute!( + io::stdout(), + EnterAlternateScreen, + event::EnableMouseCapture + ) + .map_err(|e| { + Error::validation_failed(format!("Failed to enter alternate screen: {}", e)) + })?; + + // Validate required field + if field.required.unwrap_or(false) && content.trim().is_empty() { + self.show_validation_error("Content cannot be empty") + .await?; + continue; + } + + return Ok(json!(content)); + } + } + + /// Execute date picker field with spinner-based date selection + /// Navigate with Tab/Shift+Tab between fields, Up/Down to adjust values + async fn execute_date_field(&self, field: &FieldDefinition) -> Result { + // Parse default or current date + let default_date = field.default.as_deref().unwrap_or("2024-01-01"); + let (year, month, day) = parse_iso_date(default_date)?; + + let mut state = DatePickerState { + year, + month, + day, + cursor: DateCursor::Year, + min_date: field.min_date.as_deref(), + max_date: field.max_date.as_deref(), + }; + + loop { + // Render current state + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let prompt = field.prompt.clone(); + let calendar_view = render_calendar(state.year, state.month, state.day); + let year_cursor = if state.cursor == DateCursor::Year { + "▶ " + } else { + " " + }; + let month_cursor = if state.cursor == DateCursor::Month { + "▶ " + } else { + " " + }; + let day_cursor = if state.cursor == DateCursor::Day { + "▶ " + } else { + " " + }; + + terminal.draw(|frame| { + let area = frame.area(); + let display_text = format!( + "{}:\n{}Year: {} {}Month: {} {}Day: {}\n\nInstructions: Tab=field, Up/Down=change, Enter=confirm, Esc=cancel", + prompt, + year_cursor, + state.year, + month_cursor, + state.month, + day_cursor, + state.day + ); + + let full_text = format!("{}\n{}", calendar_view, display_text); + + let paragraph = Paragraph::new(full_text) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + }) + .map_err(|e| Error::validation_failed(format!("Failed to render: {}", e)))?; + } else { + return Err(Error::validation_failed("Terminal not initialized")); + } + } + + // Event loop + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Tab => { + state.cursor = state.cursor.next(); + } + KeyCode::BackTab => { + state.cursor = state.cursor.prev(); + } + KeyCode::Up => match state.cursor { + DateCursor::Year => state.year = (state.year + 1).min(2100), + DateCursor::Month => { + state.month = if state.month == 12 { + 1 + } else { + state.month + 1 + } + } + DateCursor::Day => state.day = (state.day + 1).min(31), + }, + KeyCode::Down => match state.cursor { + DateCursor::Year => state.year = (state.year - 1).max(1900), + DateCursor::Month => { + state.month = if state.month == 1 { + 12 + } else { + state.month - 1 + } + } + DateCursor::Day => state.day = (state.day - 1).max(1), + }, + KeyCode::Enter => { + // Validate date + let date_str = + format!("{:04}-{:02}-{:02}", state.year, state.month, state.day); + if let Err(e) = validate_date(&date_str, state.min_date, state.max_date) + { + self.show_validation_error(&e).await?; + continue; + } + return Ok(json!(date_str)); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } +} + +/// Render 3-panel form layout: left (fields), center (input), bottom (buttons) +fn render_form_layout( + frame: &mut ratatui::Frame, + fields: &[FieldDefinition], + results: &std::collections::HashMap, + selected_index: usize, + focus_panel: FormPanel, + button_focus: ButtonFocus, + field_buffer: &str, +) { + use ratatui::layout::{Constraint, Direction, Layout}; + + if selected_index >= fields.len() { + return; + } + + let area = frame.area(); + + // Main layout: 3 rows (top empty, middle content, bottom buttons) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); + + let content_area = chunks[0]; + let button_area = chunks[1]; + + // Content: 2 columns (left list, right input) + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(content_area); + + let list_area = content_chunks[0]; + let input_area = content_chunks[1]; + + // Left panel: Field list (only visible fields based on conditionals) + { + let border_style = if focus_panel == FormPanel::FieldList { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let block = Block::default() + .title("Fields") + .borders(Borders::ALL) + .style(border_style); + + let mut lines = Vec::new(); + let visible_indices = get_visible_field_indices(fields, results); + + for &idx in &visible_indices { + let field = &fields[idx]; + let is_selected = idx == selected_index; + let prefix = if is_selected { "▶ " } else { " " }; + let status = if results.contains_key(&field.name) { + "✓" + } else { + " " + }; + lines.push(format!("{}{} {}", prefix, status, field.prompt)); + } + + let text = lines.join("\n"); + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, list_area); + } + + // Right panel: Input field with instructions at bottom + { + use ratatui::layout::{Constraint, Direction, Layout}; + + let border_style = if focus_panel == FormPanel::InputField { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let title_text = format!("Edit: {}", fields[selected_index].prompt); + let block = Block::default() + .title(title_text) + .borders(Borders::ALL) + .style(border_style); + + // Split input area into content and help sections + let input_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(2)]) + .split(input_area); + + let content_area = input_chunks[0]; + let help_area = input_chunks[1]; + + // Build display text based on field type + let display_text = if fields[selected_index].field_type == FieldType::Select + || fields[selected_index].field_type == FieldType::MultiSelect + { + // For select fields, show options + let mut lines = vec![]; + lines.push(format!( + " Value: {}", + if field_buffer.is_empty() { + "(empty)" + } else { + field_buffer + } + )); + lines.push("".to_string()); + + if let Some(options) = &fields[selected_index].options { + lines.push(" Options:".to_string()); + for opt in options.iter().take(4) { + let marker = if field_buffer == *opt { "▶" } else { " " }; + lines.push(format!(" {} {}", marker, opt)); + } + if options.len() > 4 { + lines.push(format!(" ... and {} more", options.len() - 4)); + } + } + lines.join("\n") + } else if fields[selected_index].field_type == FieldType::Password { + // For password fields, mask characters + let display_value = if field_buffer.is_empty() { + let placeholder = fields[selected_index] + .placeholder + .as_deref() + .unwrap_or("(empty)"); + format!("{}_", placeholder) + } else { + format!("{}_{}", "•".repeat(field_buffer.len()), "") + }; + format!(" {}", display_value) + } else if fields[selected_index].field_type == FieldType::Confirm { + // For confirm (boolean) fields, show Yes/No with indicator + let is_true = field_buffer == "true"; + let yes_indicator = if is_true { "[✓]" } else { "[ ]" }; + let no_indicator = if !is_true { "[✓]" } else { "[ ]" }; + format!(" {} Yes {} No", yes_indicator, no_indicator) + } else { + // For text fields, show input with spacing and margin + let input_value = if field_buffer.is_empty() { + let placeholder = fields[selected_index] + .placeholder + .as_deref() + .unwrap_or("(empty)"); + format!("{}_", placeholder) + } else { + format!("{}_", field_buffer) + }; + format!(" {}", input_value) + }; + + // Render content with block + let content_para = Paragraph::new(display_text).block(block); + frame.render_widget(content_para, content_area); + + // Render help text at bottom (without block, just plain text) + let help_lines = [ + " Enter: Save | Esc: Cancel | Ctrl+E: Submit | Ctrl+Q: Quit", + " ", + ]; + let help_text = help_lines.join("\n"); + let help_para = Paragraph::new(help_text).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help_para, help_area); + } + + // Bottom: Buttons + { + let button_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(button_area); + + let cancel_style = + if focus_panel == FormPanel::Buttons && button_focus == ButtonFocus::Cancel { + Style::default().fg(Color::White).bg(Color::Red) + } else if focus_panel == FormPanel::Buttons { + Style::default().fg(Color::Red) + } else { + Style::default() + }; + + let submit_style = + if focus_panel == FormPanel::Buttons && button_focus == ButtonFocus::Submit { + Style::default().fg(Color::White).bg(Color::Green) + } else if focus_panel == FormPanel::Buttons { + Style::default().fg(Color::Green) + } else { + Style::default() + }; + + let cancel_btn = Paragraph::new("[ Cancel ]").style(cancel_style); + let submit_btn = Paragraph::new("[ Submit ]").style(submit_style); + + frame.render_widget(cancel_btn, button_chunks[0]); + frame.render_widget(submit_btn, button_chunks[1]); + } +} + +/// Load field buffer with current value or default +fn load_field_buffer( + buffer: &mut String, + field: &FieldDefinition, + results: &std::collections::HashMap, +) { + buffer.clear(); + + // First, try to get the current value from results + if let Some(value) = results.get(&field.name) { + match value { + Value::String(s) => buffer.push_str(s), + Value::Bool(b) => buffer.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => buffer.push_str(&n.to_string()), + _ => {} + } + } else if let Some(default) = &field.default { + // Use default if no current value + buffer.push_str(default); + } else if field.field_type == FieldType::Confirm { + // For Confirm fields, default to "false" if no explicit default + buffer.push_str("false"); + } else if (field.field_type == FieldType::Select || field.field_type == FieldType::MultiSelect) + && field.options.is_some() + { + // For Select/MultiSelect, default to first option if no explicit default + if let Some(options) = &field.options { + if !options.is_empty() { + buffer.push_str(&options[0]); + } + } + } +} + +/// Finalize results by ensuring all fields have values (use defaults if not edited) +fn finalize_results( + results: &mut std::collections::HashMap, + fields: &[FieldDefinition], +) { + for field in fields { + // Skip if field already has a value + if results.contains_key(&field.name) { + continue; + } + + // Add default or sensible value + if let Some(default) = &field.default { + match field.field_type { + FieldType::Confirm => { + let bool_val = default.to_lowercase() == "true"; + results.insert(field.name.clone(), Value::Bool(bool_val)); + } + _ => { + results.insert(field.name.clone(), Value::String(default.clone())); + } + } + } else { + // No default, use field type sensible default + match field.field_type { + FieldType::Confirm => { + results.insert(field.name.clone(), Value::Bool(false)); + } + FieldType::Select | FieldType::MultiSelect => { + if let Some(options) = &field.options { + if !options.is_empty() { + results.insert( + field.name.clone(), + Value::String(options[0].clone()), + ); + } else { + results.insert(field.name.clone(), Value::String(String::new())); + } + } else { + results.insert(field.name.clone(), Value::String(String::new())); + } + } + _ => { + results.insert(field.name.clone(), Value::String(String::new())); + } + } + } + } +} + +/// Check if a field should be visible based on its conditional +fn is_field_visible(field: &FieldDefinition, results: &std::collections::HashMap) -> bool { + if let Some(condition) = &field.when { + crate::form_parser::evaluate_condition(condition, results) + } else { + true // No condition means always visible + } +} + +/// Get list of visible field indices based on conditionals +fn get_visible_field_indices( + fields: &[FieldDefinition], + results: &std::collections::HashMap, +) -> Vec { + fields + .iter() + .enumerate() + .filter(|(_, field)| is_field_visible(field, results)) + .map(|(idx, _)| idx) + .collect() +} + +/// Text input widget state (R-STATE-SEPARATION: pure data, no rendering logic) +struct TextInputState { + buffer: String, + cursor_pos: usize, +} + +/// Confirm widget state +struct ConfirmState { + value: bool, +} + +/// Password input widget state +struct PasswordInputState { + buffer: String, + visible: bool, + with_toggle: bool, +} + +/// Select widget state with pagination +struct SelectState { + options: Vec, + selected_index: usize, + scroll_offset: usize, + page_size: usize, + vim_mode: bool, +} + +/// MultiSelect widget state with checkboxes +struct MultiSelectState { + options: Vec, + selected: Vec, + cursor_index: usize, + scroll_offset: usize, + page_size: usize, + vim_mode: bool, +} + +/// Date picker widget state with cursor navigation +#[derive(Clone, Copy, PartialEq)] +struct DatePickerState<'a> { + year: i32, + month: u32, + day: u32, + cursor: DateCursor, + min_date: Option<&'a str>, + max_date: Option<&'a str>, +} + +/// Date cursor position (Year, Month, or Day) +#[derive(Clone, Copy, PartialEq, Debug)] +enum DateCursor { + Year, + Month, + Day, +} + +impl DateCursor { + fn next(self) -> Self { + match self { + DateCursor::Year => DateCursor::Month, + DateCursor::Month => DateCursor::Day, + DateCursor::Day => DateCursor::Year, + } + } + + fn prev(self) -> Self { + match self { + DateCursor::Year => DateCursor::Day, + DateCursor::Month => DateCursor::Year, + DateCursor::Day => DateCursor::Month, + } + } +} + +/// Render top border for display item using custom characters +fn render_top_border(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, item: &DisplayItem) { + let border_left = item.border_top_l.as_deref().unwrap_or("╭"); + let border_char = item.border_top_char.as_deref().unwrap_or("─"); + let border_len = item.border_top_len.unwrap_or(40); + let border_right = item.border_top_r.as_deref().unwrap_or("╮"); + let margin_left = item.border_margin_left.unwrap_or(0); + + let border_line = format!( + "{}{}{}", + border_left, + border_char.repeat(border_len), + border_right + ); + let margin_str = " ".repeat(margin_left); + let border_with_margin = format!("{}{}", margin_str, border_line); + + let paragraph = Paragraph::new(border_with_margin); + frame.render_widget(paragraph, area); +} + +/// Render bottom border for display item using custom characters +fn render_bottom_border( + frame: &mut ratatui::Frame, + area: ratatui::layout::Rect, + item: &DisplayItem, +) { + let border_left = item.border_bottom_l.as_deref().unwrap_or("╰"); + let border_char = item.border_bottom_char.as_deref().unwrap_or("─"); + let border_len = item.border_bottom_len.unwrap_or(40); + let border_right = item.border_bottom_r.as_deref().unwrap_or("╯"); + let margin_left = item.border_margin_left.unwrap_or(0); + + let border_line = format!( + "{}{}{}", + border_left, + border_char.repeat(border_len), + border_right + ); + let margin_str = " ".repeat(margin_left); + let border_with_margin = format!("{}{}", margin_str, border_line); + + let paragraph = Paragraph::new(border_with_margin); + frame.render_widget(paragraph, area); +} + +/// Helper to evaluate conditional display logic +fn evaluate_condition( + _condition: &str, + _results: &std::collections::HashMap, +) -> bool { + // Simple condition evaluation - placeholder for now + // Full implementation would parse conditions like "field == value" + true +} + +/// Validate custom type field input +fn validate_custom_type(input: &str, type_name: &str) -> Result<()> { + match type_name { + "i32" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected a 32-bit integer"))?; + Ok(()) + } + "i64" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected a 64-bit integer"))?; + Ok(()) + } + "u32" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected an unsigned 32-bit integer"))?; + Ok(()) + } + "u64" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected an unsigned 64-bit integer"))?; + Ok(()) + } + "f32" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected a 32-bit floating point"))?; + Ok(()) + } + "f64" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected a 64-bit floating point"))?; + Ok(()) + } + "ipv4" => { + input.parse::().map_err(|_| { + Error::validation_failed("Expected valid IPv4 address (e.g., 192.168.1.1)") + })?; + Ok(()) + } + "ipv6" => { + input + .parse::() + .map_err(|_| Error::validation_failed("Expected valid IPv6 address"))?; + Ok(()) + } + "uuid" => { + // Simple check for UUID format (no external crate dependency) + if input.len() == 36 && input.matches('-').count() == 4 { + Ok(()) + } else { + Err(Error::validation_failed( + "Expected UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + )) + } + } + "String" | "str" => Ok(()), + "bool" => match input.to_lowercase().as_str() { + "true" | "false" | "yes" | "no" | "y" | "n" | "1" | "0" => Ok(()), + _ => Err(Error::validation_failed( + "Expected: true, false, yes, no, y, n, 1, or 0", + )), + }, + _ => Ok(()), // Unknown type, accept as string + } +} + +/// Parse ISO 8601 date string (YYYY-MM-DD) to components +fn parse_iso_date(date_str: &str) -> Result<(i32, u32, u32)> { + let parts: Vec<&str> = date_str.split('-').collect(); + if parts.len() != 3 { + return Err(Error::validation_failed( + "Invalid date format (expected YYYY-MM-DD)", + )); + } + + let year = parts[0] + .parse::() + .map_err(|_| Error::validation_failed("Invalid year"))?; + let month = parts[1] + .parse::() + .map_err(|_| Error::validation_failed("Invalid month"))?; + let day = parts[2] + .parse::() + .map_err(|_| Error::validation_failed("Invalid day"))?; + + if !(1..=12).contains(&month) { + return Err(Error::validation_failed("Month must be 1-12")); + } + if !(1..=31).contains(&day) { + return Err(Error::validation_failed("Day must be 1-31")); + } + + Ok((year, month, day)) +} + +/// Validate date against min_date and max_date constraints +fn validate_date( + date_str: &str, + min_date: Option<&str>, + max_date: Option<&str>, +) -> std::result::Result<(), String> { + if let Some(min) = min_date { + if date_str < min { + return Err(format!("Date must be on or after {}", min)); + } + } + + if let Some(max) = max_date { + if date_str > max { + return Err(format!("Date must be on or before {}", max)); + } + } + + Ok(()) +} + +/// Get the day of the week for the first day of the month (0=Monday, 6=Sunday) +/// Using Zeller's congruence for simplicity +fn first_day_of_month(year: i32, month: u32) -> usize { + let (adj_month, adj_year) = if month < 3 { + (month + 12, year - 1) + } else { + (month, year) + }; + + let k = adj_year % 100; + let j = adj_year / 100; + let adj_month = adj_month as i32; + let h = (1 + (13 * (adj_month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7; + + // Convert Saturday=0 to Monday=0 + ((h + 5) % 7).max(0) as usize +} + +/// Get number of days in a month +fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { + 29 + } else { + 28 + } + } + _ => 30, + } +} + +/// Render calendar as ASCII grid +fn render_calendar(year: i32, month: u32, selected_day: u32) -> String { + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + let mut calendar = format!("\n {} {}\n", month_names[(month - 1) as usize], year); + calendar.push_str(&format!( + " {} {} {} {} {} {} {}\n", + day_names[0], + day_names[1], + day_names[2], + day_names[3], + day_names[4], + day_names[5], + day_names[6] + )); + + let first_day = first_day_of_month(year, month); + let days = days_in_month(year, month); + + // Padding for first week + for _ in 0..first_day { + calendar.push_str(" "); + } + + // Days of month + for day in 1..=days { + let day_display = if day == selected_day { + format!("[{:2}]", day) + } else { + format!(" {:2} ", day) + }; + calendar.push_str(&day_display); + + // New line on Sunday + if (first_day + (day as usize - 1)) % 7 == 6 { + calendar.push('\n'); + } + } + + calendar.push('\n'); + calendar +} + +/// Parse and convert custom type field input to appropriate JSON value +fn parse_custom_value(input: &str, type_name: &str) -> Value { + match type_name { + "i32" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "i64" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "u32" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "u64" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "f32" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "f64" => input + .parse::() + .map(|v| json!(v)) + .unwrap_or_else(|_| json!(input)), + "bool" => match input.to_lowercase().as_str() { + "true" | "yes" | "y" | "1" => json!(true), + "false" | "no" | "n" | "0" => json!(false), + _ => json!(input), + }, + _ => json!(input), // Default to string + } +} + +/// Render complete form UI with all fields visible +/// Shows current field with focus highlighting +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ratatui_backend_new() { + let backend = RatatuiBackend::new(); + assert_eq!(backend.name(), "tui"); + } + + #[test] + fn test_ratatui_backend_default() { + let backend = RatatuiBackend::default(); + assert_eq!(backend.name(), "tui"); + } + + #[test] + fn test_terminal_guard_drop() { + // Verify TerminalGuard can be created and dropped + let _guard = TerminalGuard; + // Drop should be called on scope exit without panicking + } + + #[test] + fn test_parse_iso_date_valid() { + let (year, month, day) = parse_iso_date("2024-12-25").unwrap(); + assert_eq!(year, 2024); + assert_eq!(month, 12); + assert_eq!(day, 25); + } + + #[test] + fn test_parse_iso_date_invalid_format() { + assert!(parse_iso_date("2024/12/25").is_err()); + assert!(parse_iso_date("12-25-2024").is_err()); + } + + #[test] + fn test_parse_iso_date_invalid_month() { + assert!(parse_iso_date("2024-13-25").is_err()); + assert!(parse_iso_date("2024-00-25").is_err()); + } + + #[test] + fn test_parse_iso_date_invalid_day() { + assert!(parse_iso_date("2024-12-32").is_err()); + assert!(parse_iso_date("2024-12-00").is_err()); + } + + #[test] + fn test_validate_date_within_range() { + assert!(validate_date("2024-06-15", Some("2024-01-01"), Some("2024-12-31")).is_ok()); + } + + #[test] + fn test_validate_date_before_min() { + assert!(validate_date("2023-12-31", Some("2024-01-01"), None).is_err()); + } + + #[test] + fn test_validate_date_after_max() { + assert!(validate_date("2025-01-01", None, Some("2024-12-31")).is_err()); + } + + #[test] + fn test_days_in_month_31() { + assert_eq!(days_in_month(2024, 1), 31); // January + assert_eq!(days_in_month(2024, 3), 31); // March + assert_eq!(days_in_month(2024, 5), 31); // May + assert_eq!(days_in_month(2024, 7), 31); // July + assert_eq!(days_in_month(2024, 8), 31); // August + assert_eq!(days_in_month(2024, 10), 31); // October + assert_eq!(days_in_month(2024, 12), 31); // December + } + + #[test] + fn test_days_in_month_30() { + assert_eq!(days_in_month(2024, 4), 30); // April + assert_eq!(days_in_month(2024, 6), 30); // June + assert_eq!(days_in_month(2024, 9), 30); // September + assert_eq!(days_in_month(2024, 11), 30); // November + } + + #[test] + fn test_days_in_month_february_leap() { + assert_eq!(days_in_month(2024, 2), 29); // 2024 is leap year + assert_eq!(days_in_month(2023, 2), 28); // 2023 is not leap year + } + + #[test] + fn test_validate_custom_type_i32() { + assert!(validate_custom_type("42", "i32").is_ok()); + assert!(validate_custom_type("-42", "i32").is_ok()); + assert!(validate_custom_type("abc", "i32").is_err()); + } + + #[test] + fn test_validate_custom_type_f64() { + assert!(validate_custom_type("3.14", "f64").is_ok()); + assert!(validate_custom_type("-2.5", "f64").is_ok()); + assert!(validate_custom_type("abc", "f64").is_err()); + } + + #[test] + fn test_validate_custom_type_ipv4() { + assert!(validate_custom_type("192.168.1.1", "ipv4").is_ok()); + assert!(validate_custom_type("255.255.255.255", "ipv4").is_ok()); + assert!(validate_custom_type("256.1.1.1", "ipv4").is_err()); + } + + #[test] + fn test_validate_custom_type_bool() { + assert!(validate_custom_type("true", "bool").is_ok()); + assert!(validate_custom_type("false", "bool").is_ok()); + assert!(validate_custom_type("yes", "bool").is_ok()); + assert!(validate_custom_type("no", "bool").is_ok()); + assert!(validate_custom_type("maybe", "bool").is_err()); + } + + #[test] + fn test_parse_custom_value_i32() { + assert_eq!(parse_custom_value("42", "i32"), json!(42)); + assert_eq!(parse_custom_value("abc", "i32"), json!("abc")); + } + + #[test] + fn test_parse_custom_value_bool() { + assert_eq!(parse_custom_value("true", "bool"), json!(true)); + assert_eq!(parse_custom_value("false", "bool"), json!(false)); + assert_eq!(parse_custom_value("yes", "bool"), json!(true)); + assert_eq!(parse_custom_value("no", "bool"), json!(false)); + } + + #[test] + fn test_date_cursor_navigation() { + let mut cursor = DateCursor::Year; + cursor = cursor.next(); + assert_eq!(cursor, DateCursor::Month); + cursor = cursor.next(); + assert_eq!(cursor, DateCursor::Day); + cursor = cursor.next(); + assert_eq!(cursor, DateCursor::Year); + } + + #[test] + fn test_date_cursor_prev_navigation() { + let mut cursor = DateCursor::Day; + cursor = cursor.prev(); + assert_eq!(cursor, DateCursor::Month); + cursor = cursor.prev(); + assert_eq!(cursor, DateCursor::Year); + cursor = cursor.prev(); + assert_eq!(cursor, DateCursor::Day); + } + + #[test] + fn test_render_calendar_contains_month() { + let calendar = render_calendar(2024, 12, 25); + assert!(calendar.contains("December")); + assert!(calendar.contains("2024")); + } + + #[test] + fn test_render_calendar_highlights_selected_day() { + let calendar = render_calendar(2024, 12, 25); + assert!(calendar.contains("[25]")); + } + + #[test] + fn test_render_calendar_valid_days() { + let calendar = render_calendar(2024, 2, 15); + // February 2024 has 29 days (leap year) + assert!(calendar.contains("29")); + } + + #[test] + fn test_first_day_of_month_known_dates() { + // These are known day-of-week values + // January 1, 2024 is a Monday (0) + assert_eq!(first_day_of_month(2024, 1), 0); + } +} diff --git a/crates/typedialog-core/src/backends/web/mod.rs b/crates/typedialog-core/src/backends/web/mod.rs new file mode 100644 index 0000000..ab80c97 --- /dev/null +++ b/crates/typedialog-core/src/backends/web/mod.rs @@ -0,0 +1,1228 @@ +//! Web Backend using axum and HTMX for web-based forms +//! +//! This backend provides a REST API server with HTML forms using HTMX. +//! Forms are served over HTTP and can be accessed from any web browser. + +use async_trait::async_trait; +use serde_json::{Value, json}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{RwLock, oneshot}; +use tokio::task::JoinHandle; + +use crate::error::{Error, Result}; +use crate::form_parser::{FieldDefinition, DisplayItem, FieldType}; +use super::{FormBackend, RenderContext}; + +/// Type alias for complete form submission channel +type CompleteFormChannel = Arc>>>>; + +/// Shared form state accessible to all handlers (A-SHARED-STATE pattern) +#[derive(Clone)] +pub struct WebFormState { + #[allow(dead_code)] + form: Arc>>, + current_field: Arc>>, + field_channels: Arc>>>, + results: Arc>>, + display_buffer: Arc>>, + /// Channel for complete form submission (used in Complete display mode) + complete_form_tx: CompleteFormChannel, + /// Flag indicating if we're in complete form mode (all fields at once) + is_complete_mode: Arc>, + /// Cached fields for complete mode rendering + complete_fields: Arc>>, +} + +impl WebFormState { + fn new() -> Self { + WebFormState { + form: Arc::new(RwLock::new(None)), + current_field: Arc::new(RwLock::new(None)), + field_channels: Arc::new(RwLock::new(HashMap::new())), + results: Arc::new(RwLock::new(HashMap::new())), + display_buffer: Arc::new(RwLock::new(Vec::new())), + complete_form_tx: Arc::new(RwLock::new(None)), + is_complete_mode: Arc::new(RwLock::new(false)), + complete_fields: Arc::new(RwLock::new(Vec::new())), + } + } +} + +/// Web Backend implementation using axum +pub struct WebBackend { + port: u16, + state: Option>, + server_handle: Option>, + shutdown_tx: Option>, +} + +#[derive(Deserialize, Default)] +struct FieldSubmission { + #[serde(default)] + value: String, + field_type: String, + #[serde(default)] + values: String, // Changed to String - will be comma-separated values +} + +impl WebBackend { + /// Create a new Web backend instance with the specified port + pub fn new(port: u16) -> Self { + WebBackend { + port, + state: None, + server_handle: None, + shutdown_tx: None, + } + } +} + +#[cfg(feature = "web")] +use axum::{ + extract::{State, Path}, + http::{StatusCode, HeaderMap}, + response::IntoResponse, + routing::{get, post}, + Form, Json, Router, +}; + +#[async_trait] +impl FormBackend for WebBackend { + async fn initialize(&mut self) -> Result<()> { + #[cfg(feature = "web")] + { + use std::net::SocketAddr; + + // Create shared state (A-SHARED-STATE guideline) + let form_state = Arc::new(WebFormState::new()); + self.state = Some(form_state.clone()); + + // Graceful shutdown channel + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + self.shutdown_tx = Some(shutdown_tx); + + // Router composition (A-ROUTER-COMPOSITION guideline) + let app = Router::new() + .route("/", get(index_handler)) + .route("/api/form", get(get_form_handler)) + .route("/api/field/{name}", post(submit_field_handler)) + .route("/api/form/complete", post(submit_complete_form_handler)) + .with_state(form_state); + + // Server startup with graceful shutdown + let addr = SocketAddr::from(([127, 0, 0, 1], self.port)); + let listener = tokio::net::TcpListener::bind(addr).await + .map_err(|e| Error::validation_failed(format!("Failed to bind to port {}: {}", self.port, e)))?; + + let server = axum::serve(listener, app) + .with_graceful_shutdown(async { + shutdown_rx.await.ok(); + }); + + let handle = tokio::spawn(async move { + if let Err(e) = server.await { + eprintln!("Server error: {}", e); + } + }); + + self.server_handle = Some(handle); + println!("Web UI available at http://localhost:{}", self.port); + tokio::time::sleep(Duration::from_millis(500)).await; + + Ok(()) + } + + #[cfg(not(feature = "web"))] + { + Err(Error::validation_failed("Web feature not enabled. Enable with --features web".to_string())) + } + } + + async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> { + // Check conditional rendering + if let Some(condition) = &item.when { + if !evaluate_condition(condition, &context.results) { + return Ok(()); + } + } + + if let Some(state) = &self.state { + let mut buffer = state.display_buffer.write().await; + + // Build HTML with custom border support + let mut html = String::new(); + let margin_left = item.margin_left.unwrap_or(0); + let border_margin_left = item.border_margin_left.unwrap_or(0); + let content_margin_left = item.content_margin_left.unwrap_or(2); + + let style = format!("margin-left: {}ch;", margin_left); + let border_style = format!("margin-left: {}ch; font-family: monospace;", border_margin_left); + let content_style = format!("margin-left: {}ch;", content_margin_left); + + // Render top border if specified + if item.border_top.unwrap_or(false) { + let border_left = item.border_top_l.as_deref().unwrap_or("╭"); + let border_char = item.border_top_char.as_deref().unwrap_or("─"); + let border_len = item.border_top_len.unwrap_or(40); + let border_right = item.border_top_r.as_deref().unwrap_or("╮"); + let border_line = format!( + "{}{}{}", + border_left, + border_char.repeat(border_len), + border_right + ); + html.push_str(&format!( + "
{}
\n", + border_style, html_escape(&border_line) + )); + } + + // Render title + if let Some(title) = &item.title { + html.push_str(&format!( + "

{}

\n", + content_style, html_escape(title) + )); + } + + // Render content + if let Some(content) = &item.content { + html.push_str(&format!( + "

{}

\n", + content_style, html_escape(content) + )); + } + + // Render bottom border if specified + if item.border_bottom.unwrap_or(false) { + let border_left = item.border_bottom_l.as_deref().unwrap_or("╰"); + let border_char = item.border_bottom_char.as_deref().unwrap_or("─"); + let border_len = item.border_bottom_len.unwrap_or(40); + let border_right = item.border_bottom_r.as_deref().unwrap_or("╯"); + let border_line = format!( + "{}{}{}", + border_left, + border_char.repeat(border_len), + border_right + ); + html.push_str(&format!( + "
{}
\n", + border_style, html_escape(&border_line) + )); + } + + html.push_str(&format!("
\n", style)); + buffer.push(html); + } + + Ok(()) + } + + async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result { + let state = self.state.as_ref() + .ok_or_else(|| Error::validation_failed("Server not initialized"))?; + + // Create oneshot channel for field submission + let (tx, rx) = oneshot::channel(); + + // Store field info and channel + { + let mut current = state.current_field.write().await; + *current = Some(field.clone()); + + let mut channels = state.field_channels.write().await; + channels.insert(field.name.clone(), tx); + } + + // Wait for field submission with timeout + match tokio::time::timeout(Duration::from_secs(300), rx).await { + Ok(Ok(value)) => { + // Validate required fields + if field.required.unwrap_or(false) && value.is_null() { + return self.execute_field(field, _context).await; + } + + // Store result + { + let mut results = state.results.write().await; + results.insert(field.name.clone(), value.clone()); + } + + // Clear current field + { + let mut current = state.current_field.write().await; + *current = None; + } + + Ok(value) + } + Ok(Err(_)) => Err(Error::cancelled()), + Err(_) => Err(Error::validation_failed("Field submission timeout".to_string())), + } + } + + async fn execute_form_complete( + &mut self, + form: &crate::form_parser::FormDefinition, + items: &[DisplayItem], + fields: &[FieldDefinition], + ) -> Result> { + let state = self.state.as_ref() + .ok_or_else(|| Error::validation_failed("Server not initialized"))?; + + // Set complete mode flag and cache fields for get_form_handler + { + let mut is_complete = state.is_complete_mode.write().await; + *is_complete = true; + + let mut cached_fields = state.complete_fields.write().await; + *cached_fields = fields.to_vec(); + } + + // Render all display items + for item in items { + self.render_display_item(item, &RenderContext { + results: HashMap::new(), + locale: form.locale.clone(), + }).await?; + } + + // Create oneshot channel for complete form submission + let (tx, rx) = oneshot::channel(); + { + let mut complete_tx = state.complete_form_tx.write().await; + *complete_tx = Some(tx); + } + + // Clear any old results and field channels + { + let mut results = state.results.write().await; + results.clear(); + + let mut channels = state.field_channels.write().await; + channels.clear(); + } + + // Wait for complete form submission with timeout + match tokio::time::timeout(Duration::from_secs(300), rx).await { + Ok(Ok(mut all_results)) => { + // Validate required fields + for field in fields { + if field.required.unwrap_or(false) { + if let Some(value) = all_results.get(&field.name) { + if value.is_null() || (value.is_string() && value.as_str().unwrap_or("").is_empty()) { + // Required field is empty - would need to show error and retry + // For now, just ensure field is in results + all_results.insert(field.name.clone(), Value::Null); + } + } else { + all_results.insert(field.name.clone(), Value::Null); + } + } + } + + // Store results in state for reference + { + let mut results = state.results.write().await; + *results = all_results.clone(); + } + + // Clear complete mode flag + { + let mut is_complete = state.is_complete_mode.write().await; + *is_complete = false; + } + + Ok(all_results) + } + Ok(Err(_)) => Err(Error::cancelled()), + Err(_) => Err(Error::validation_failed("Form submission timeout".to_string())), + } + } + + async fn shutdown(&mut self) -> Result<()> { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + + if let Some(handle) = self.server_handle.take() { + match tokio::time::timeout(Duration::from_secs(5), handle).await { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(Error::validation_failed(format!("Server join error: {}", e))), + Err(_) => { + // Timeout - server didn't shutdown gracefully + Err(Error::validation_failed("Server shutdown timeout".to_string())) + } + } + } else { + Ok(()) + } + } + + fn is_available() -> bool { + #[cfg(feature = "web")] + { + true + } + + #[cfg(not(feature = "web"))] + { + false + } + } + + fn name(&self) -> &str { + "web" + } +} + +/// HTTP Handlers (A-EXTRACTORS-FIRST pattern from AXUM.md) +/// Each handler uses State extractor for type-safe access to shared state +#[cfg(feature = "web")] +async fn index_handler(State(_state): State>) -> impl IntoResponse { + Html( + r#" + + + Form Inquire + + + + +
+

Form Inquire

+
+
+ + +"# + ) +} + +#[cfg(feature = "web")] +async fn get_form_handler(State(state): State>) -> impl IntoResponse { + let display_items = state.display_buffer.read().await; + // Keep only the last 3 display items to avoid overwhelming the page + let recent_items: Vec = display_items.iter().cloned().rev().take(3).collect::>(); + let display_html: String = recent_items.into_iter().rev().collect::>().join("\n"); + + // Check if we're in complete form mode + let is_complete = *state.is_complete_mode.read().await; + + let field_html = if is_complete { + // Complete form mode: render all fields in a single form + let fields = state.complete_fields.read().await; + render_complete_form(&fields) + } else { + // Field-by-field mode: render single current field + let current_field = state.current_field.read().await; + if let Some(field) = current_field.as_ref() { + render_field_to_html(field) + } else { + "

Form submitted!

".to_string() + } + }; + + Html(format!( + r#"
{}
{}
"#, + display_html, field_html + )) +} + +#[cfg(feature = "web")] +async fn submit_field_handler( + State(state): State>, + Path(field_name): Path, + Form(payload): Form, +) -> impl IntoResponse { + // Parse value based on field type + let value = match payload.field_type.as_str() { + "confirm" => json!(payload.value.to_lowercase() == "true"), + "multiselect" => { + // Parse comma-separated values into array, filter empty strings + let values: Vec = payload.values + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + json!(values) + } + "custom" => { + // Get custom type from current field for validation + let current_field = state.current_field.read().await; + if let Some(field) = current_field.as_ref() { + let custom_type = field.custom_type.as_deref().unwrap_or("String"); + + // Validate custom type + if let Err(e) = validate_custom_type_web(&payload.value, custom_type) { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json(json!({"success": false, "error": e})) + ); + } + + // Convert to appropriate type + parse_custom_value_web(&payload.value, custom_type) + } else { + json!(payload.value) + } + } + "editor" => { + // Validate required for editor + let current_field = state.current_field.read().await; + if let Some(field) = current_field.as_ref() { + if field.required.unwrap_or(false) && payload.value.trim().is_empty() { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json(json!({"success": false, "error": "Content cannot be empty"})) + ); + } + } + json!(payload.value) + } + _ => json!(payload.value), + }; + + // Send via channel + { + let mut channels = state.field_channels.write().await; + if let Some(tx) = channels.remove(&field_name) { + let _ = tx.send(value.clone()); + } + } + + // Update results + { + let mut results = state.results.write().await; + results.insert(field_name, value); + } + + // A-TYPED-RESPONSES: Build response with HX-Trigger header + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + + ( + StatusCode::OK, + headers, + Json(json!({"success": true})) + ) +} + +#[cfg(feature = "web")] +async fn submit_complete_form_handler( + State(state): State>, + Form(payload): Form>, +) -> impl IntoResponse { + // Parse all form fields into a results map + let mut all_results = HashMap::new(); + + // Process each submitted field + for (key, value) in payload.iter() { + // Skip metadata fields + if key.starts_with("_") { + continue; + } + + // Handle multiselect fields (comma-separated values) + if key.ends_with("_values") { + let field_name = key.strip_suffix("_values").unwrap_or(key); + let values: Vec = value + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + all_results.insert(field_name.to_string(), json!(values)); + } else if !key.ends_with("_type") { + // Regular fields - store value as string (will be converted later) + all_results.insert(key.clone(), json!(value)); + } + } + + // Send through channel + { + let mut complete_tx = state.complete_form_tx.write().await; + if let Some(tx) = complete_tx.take() { + let _ = tx.send(all_results.clone()); + } + } + + // Update state results + { + let mut results = state.results.write().await; + *results = all_results; + } + + // A-TYPED-RESPONSES: Build response with HX-Trigger header + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formComplete".parse().unwrap()); + + ( + StatusCode::OK, + headers, + Json(json!({"success": true})) + ) +} + +#[cfg(feature = "web")] +use axum::response::Html; + +/// Render all fields in a single form for complete mode +fn render_complete_form(fields: &[FieldDefinition]) -> String { + let mut fields_html = String::new(); + + for field in fields { + fields_html.push_str(&render_field_for_complete_form(field)); + } + + format!( + r##"
+
+ {} + +
+
"##, + fields_html + ) +} + +/// Render a single field for inclusion in the complete form (without individual submit button) +fn render_field_for_complete_form(field: &FieldDefinition) -> String { + match field.field_type { + FieldType::Text => { + let placeholder = field.placeholder.as_deref().unwrap_or(""); + let default = field.default.as_deref().unwrap_or(""); + format!( + r#"
+ + +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + html_escape(default), + if field.required.unwrap_or(false) { "required" } else { "" } + ) + } + FieldType::Confirm => { + format!( + r#"
+ +
+ + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(&field.name) + ) + } + FieldType::Password => { + let placeholder = field.placeholder.as_deref().unwrap_or(""); + format!( + r#"
+ + +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + if field.required.unwrap_or(false) { "required" } else { "" } + ) + } + FieldType::Select => { + let options = field.options.as_ref().map(|opts| { + opts.iter() + .map(|opt| format!("", html_escape(opt), html_escape(opt))) + .collect::>() + .join("\n") + }).unwrap_or_default(); + + format!( + r#"
+ + +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + if field.required.unwrap_or(false) { "required" } else { "" }, + options + ) + } + FieldType::MultiSelect => { + let field_name = &field.name; + let options = field.options.as_ref().map(|opts| { + opts.iter() + .map(|opt| format!( + r#""#, + html_escape(field_name), + html_escape(opt), + html_escape(opt) + )) + .collect::>() + .join("\n") + }).unwrap_or_default(); + + format!( + r#"
+ + +
+ {} +
+ +
"#, + html_escape(&field.prompt), + html_escape(field_name), + html_escape(field_name), + options, + html_escape(field_name) + ) + } + FieldType::Custom => { + let custom_type = field.custom_type.as_deref().unwrap_or("String"); + let placeholder = field.placeholder.as_deref().unwrap_or(""); + let type_hint = format!("Type: {}", custom_type); + + format!( + r#"
+ + + {} +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + html_escape(&type_hint), + if field.required.unwrap_or(false) { "required" } else { "" }, + html_escape(&type_hint) + ) + } + FieldType::Editor => { + let rows = 10; + let cols = 60; + let prefix_text = field.prefix_text.as_deref().unwrap_or(""); + + format!( + r#"
+ + + File type: {} +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + rows, + cols, + if field.required.unwrap_or(false) { "required" } else { "" }, + html_escape(prefix_text), + html_escape(field.file_extension.as_deref().unwrap_or("text")) + ) + } + FieldType::Date => { + let value = field.default.as_deref().unwrap_or(""); + let min = field.min_date.as_deref().unwrap_or(""); + let max = field.max_date.as_deref().unwrap_or(""); + + format!( + r#"
+ + +
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(value), + html_escape(min), + html_escape(max), + if field.required.unwrap_or(false) { "required" } else { "" } + ) + } + } +} + +/// Render a field to HTML based on its type (A-EXTRACTORS-FIRST supports diverse types) +fn render_field_to_html(field: &FieldDefinition) -> String { + match field.field_type { + FieldType::Text => { + let placeholder = field.placeholder.as_deref().unwrap_or(""); + format!( + r#"
+ +
+ + + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder) + ) + } + FieldType::Confirm => { + format!( + r#"
+ +
+ + + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name) + ) + } + FieldType::Password => { + format!( + r#"
+ +
+ + + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name) + ) + } + FieldType::Select => { + let options = field.options.as_ref().map(|opts| { + opts.iter() + .map(|opt| format!("", html_escape(opt), html_escape(opt))) + .collect::>() + .join("\n") + }).unwrap_or_default(); + + format!( + r#"
+ +
+ + + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + options + ) + } + FieldType::MultiSelect => { + let field_name = &field.name; + let options = field.options.as_ref().map(|opts| { + opts.iter() + .map(|opt| format!( + r#""#, + html_escape(field_name), + html_escape(opt), + html_escape(opt) + )) + .collect::>() + .join("\n") + }).unwrap_or_default(); + + let form_id = format!("form_{}", html_escape(field_name)); + format!( + r#"
+ +
+ + +
+ {} +
+ +
+
+ "#, + html_escape(&field.prompt), + form_id, + html_escape(field_name), + html_escape(field_name), + options, + html_escape(field_name) + ) + } + FieldType::Custom => { + let custom_type = field.custom_type.as_deref().unwrap_or("String"); + let placeholder = field.placeholder.as_deref().unwrap_or(""); + let type_hint = format!("Type: {}", custom_type); + + format!( + r#"
+ +
+ + + {} + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + html_escape(&type_hint), + html_escape(&type_hint) + ) + } + FieldType::Editor => { + let rows = 10; + let cols = 60; + let prefix_text = field.prefix_text.as_deref().unwrap_or(""); + + format!( + r#"
+ +
+ + + File type: {} + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + rows, + cols, + html_escape(prefix_text), + html_escape(field.file_extension.as_deref().unwrap_or("text")) + ) + } + FieldType::Date => { + let value = field.default.as_deref().unwrap_or(""); + let min = field.min_date.as_deref().unwrap_or(""); + let max = field.max_date.as_deref().unwrap_or(""); + + format!( + r#"
+ +
+ + + +
+
"#, + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(value), + html_escape(min), + html_escape(max) + ) + } + } +} + +/// HTML escape function to prevent injection +fn html_escape(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} + +/// Validate custom type field input (Web backend version) +fn validate_custom_type_web(input: &str, type_name: &str) -> std::result::Result<(), String> { + match type_name { + "i32" => { + input.parse::() + .map_err(|_| "Expected a 32-bit integer".to_string())?; + Ok(()) + } + "i64" => { + input.parse::() + .map_err(|_| "Expected a 64-bit integer".to_string())?; + Ok(()) + } + "u32" => { + input.parse::() + .map_err(|_| "Expected an unsigned 32-bit integer".to_string())?; + Ok(()) + } + "u64" => { + input.parse::() + .map_err(|_| "Expected an unsigned 64-bit integer".to_string())?; + Ok(()) + } + "f32" => { + input.parse::() + .map_err(|_| "Expected a 32-bit floating point".to_string())?; + Ok(()) + } + "f64" => { + input.parse::() + .map_err(|_| "Expected a 64-bit floating point".to_string())?; + Ok(()) + } + "ipv4" => { + input.parse::() + .map_err(|_| "Expected valid IPv4 address (e.g., 192.168.1.1)".to_string())?; + Ok(()) + } + "ipv6" => { + input.parse::() + .map_err(|_| "Expected valid IPv6 address".to_string())?; + Ok(()) + } + "uuid" => { + if input.len() == 36 && input.matches('-').count() == 4 { + Ok(()) + } else { + Err("Expected UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".to_string()) + } + } + "String" | "str" => Ok(()), + "bool" => { + match input.to_lowercase().as_str() { + "true" | "false" | "yes" | "no" | "y" | "n" | "1" | "0" => Ok(()), + _ => Err("Expected: true, false, yes, no, y, n, 1, or 0".to_string()), + } + } + _ => Ok(()), // Unknown type, accept as string + } +} + +/// Parse and convert custom type field input to appropriate JSON value (Web backend version) +fn parse_custom_value_web(input: &str, type_name: &str) -> Value { + match type_name { + "i32" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "i64" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "u32" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "u64" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "f32" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "f64" => { + input.parse::().map(|v| json!(v)).unwrap_or_else(|_| json!(input)) + } + "bool" => { + match input.to_lowercase().as_str() { + "true" | "yes" | "y" | "1" => json!(true), + "false" | "no" | "n" | "0" => json!(false), + _ => json!(input), + } + } + _ => json!(input), // Default to string + } +} + +/// Evaluate condition for display items (duplicate of form_parser logic for safety) +/// This provides an extra layer of filtering at render time +fn evaluate_condition(condition: &str, results: &HashMap) -> bool { + let condition = condition.trim(); + + // Check string operators first (word boundaries) + let string_operators = ["contains", "startswith", "endswith"]; + for op_str in &string_operators { + if let Some(pos) = condition.find(op_str) { + let before_ok = pos == 0 || !condition[..pos].chars().last().unwrap_or(' ').is_alphanumeric(); + let after_ok = pos + op_str.len() >= condition.len() || + !condition[pos + op_str.len()..].chars().next().unwrap_or(' ').is_alphanumeric(); + + if before_ok && after_ok { + let left = condition[..pos].trim(); + let right = condition[pos + op_str.len()..].trim(); + + let field_value = results.get(left).cloned().unwrap_or(Value::Null); + let field_str = value_to_string_web(&field_value); + let expected = parse_condition_value_web(right); + let expected_str = value_to_string_web(&expected); + + match *op_str { + "contains" => return field_str.contains(&expected_str), + "startswith" => return field_str.starts_with(&expected_str), + "endswith" => return field_str.ends_with(&expected_str), + _ => {} + } + } + } + } + + // Parse numeric/comparison operators in order of precedence (longest first to avoid partial matches) + let operators = [ + ("<=", "le"), (">=", "ge"), ("==", "eq"), ("!=", "ne"), + (">", "gt"), ("<", "lt"), + ]; + + for (op_str, _op_name) in &operators { + if let Some(pos) = condition.find(op_str) { + let left = condition[..pos].trim(); + let right = condition[pos + op_str.len()..].trim(); + + // Get the field value from results + let field_value = results.get(left).cloned().unwrap_or(Value::Null); + + // Parse the right side as value (handle quoted strings and raw values) + let expected = parse_condition_value_web(right); + + // Perform comparison + match *op_str { + "==" => return values_equal_web(&field_value, &expected), + "!=" => return !values_equal_web(&field_value, &expected), + ">" => return compare_values_web(&field_value, &expected) == std::cmp::Ordering::Greater, + "<" => return compare_values_web(&field_value, &expected) == std::cmp::Ordering::Less, + ">=" => { + let cmp = compare_values_web(&field_value, &expected); + return cmp == std::cmp::Ordering::Greater || cmp == std::cmp::Ordering::Equal; + } + "<=" => { + let cmp = compare_values_web(&field_value, &expected); + return cmp == std::cmp::Ordering::Less || cmp == std::cmp::Ordering::Equal; + } + _ => {} + } + } + } + + true +} + +fn parse_condition_value_web(s: &str) -> Value { + let s = s.trim(); + if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) { + return json!(s[1..s.len() - 1].to_string()); + } + if let Ok(n) = s.parse::() { return json!(n); } + if let Ok(n) = s.parse::() { return json!(n); } + match s.to_lowercase().as_str() { + "true" | "yes" | "1" => json!(true), + "false" | "no" | "0" => json!(false), + _ => json!(s.to_string()), + } +} + +fn value_to_string_web(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +fn values_equal_web(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::String(s1), Value::String(s2)) => s1 == s2, + (Value::Number(n1), Value::Number(n2)) => n1 == n2, + (Value::Bool(b1), Value::Bool(b2)) => b1 == b2, + (Value::Null, Value::Null) => true, + (Value::String(s), Value::Number(n)) | (Value::Number(n), Value::String(s)) => { + s.parse::().ok().and_then(|pv| n.as_f64().map(|nv| (pv - nv).abs() < 1e-10)).unwrap_or(false) + } + _ => false, + } +} + +fn compare_values_web(a: &Value, b: &Value) -> std::cmp::Ordering { + use std::cmp::Ordering; + let a_num = match a { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse::().ok(), + Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + }; + let b_num = match b { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse::().ok(), + Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + }; + match (a_num, b_num) { + (Some(an), Some(bn)) => { + if (an - bn).abs() < 1e-10 { Ordering::Equal } + else if an > bn { Ordering::Greater } + else { Ordering::Less } + } + _ => { + let a_str = value_to_string_web(a); + let b_str = value_to_string_web(b); + a_str.cmp(&b_str) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_web_backend_new() { + let backend = WebBackend::new(3000); + assert_eq!(backend.name(), "web"); + assert_eq!(backend.port, 3000); + } + + #[test] + fn test_web_backend_available() { + assert!(WebBackend::is_available()); + } + + #[tokio::test] + async fn test_web_backend_lifecycle() { + let mut backend = WebBackend::new(3001); + assert!(backend.initialize().await.is_ok()); + assert!(backend.shutdown().await.is_ok()); + } +} diff --git a/crates/typedialog-core/src/cli_common.rs b/crates/typedialog-core/src/cli_common.rs new file mode 100644 index 0000000..43deaa3 --- /dev/null +++ b/crates/typedialog-core/src/cli_common.rs @@ -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 + + TUI 3-panel terminal UI (best for interactive use) + Run: typedialog-tui + Or: TYPEDIALOG_BACKEND=tui typedialog form + + Web Browser-based interface (best for teams) + Run: typedialog-web --port 8080 + Or: TYPEDIALOG_BACKEND=web typedialog form + +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 + +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 + +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 + + TUI 3-panel terminal UI (best for interactive use) + Run: typedialog-tui + Or: TYPEDIALOG_BACKEND=tui typedialog form + + Web Browser-based interface (best for teams) + Run: typedialog-web --port 8080 + Or: TYPEDIALOG_BACKEND=web typedialog form + +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)"; diff --git a/crates/typedialog-core/src/config/loader.rs b/crates/typedialog-core/src/config/loader.rs new file mode 100644 index 0000000..b38e627 --- /dev/null +++ b/crates/typedialog-core/src/config/loader.rs @@ -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 { + 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 { + #[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")) + } +} diff --git a/crates/typedialog-core/src/config/mod.rs b/crates/typedialog-core/src/config/mod.rs new file mode 100644 index 0000000..698465a --- /dev/null +++ b/crates/typedialog-core/src/config/mod.rs @@ -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, + + /// 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) -> 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) -> Self { + self.fallback_locale = locale.into(); + self + } +} diff --git a/crates/typedialog-core/src/error.rs b/crates/typedialog-core/src/error.rs new file mode 100644 index 0000000..c731ba8 --- /dev/null +++ b/crates/typedialog-core/src/error.rs @@ -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) -> 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) -> 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) -> Self { + Self::new(ErrorKind::ValidationFailed, msg) + } + + /// Create an i18n error + pub fn i18n_failed(msg: impl Into) -> Self { + Self::new(ErrorKind::I18nFailed, msg) + } + + /// Create a template error + pub fn template_failed(msg: impl Into) -> Self { + Self::new(ErrorKind::TemplateFailed, msg) + } + + /// Create a config error + pub fn config_not_found(msg: impl Into) -> 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 for Error { + fn from(err: io::Error) -> Self { + Self::io(err) + } +} + +impl From for Error { + fn from(err: toml::de::Error) -> Self { + Self::toml_parse(err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Self::new(ErrorKind::Other, format!("JSON error: {}", err)) + } +} + +impl From for Error { + fn from(err: serde_yaml::Error) -> Self { + Self::new(ErrorKind::Other, format!("YAML error: {}", err)) + } +} + +impl From for Error { + fn from(err: chrono::ParseError) -> Self { + Self::new(ErrorKind::ValidationFailed, format!("Date parsing error: {}", err)) + } +} + +impl From 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 = std::result::Result; + +#[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()); + } +} diff --git a/crates/typedialog-core/src/form_parser.rs b/crates/typedialog-core/src/form_parser.rs new file mode 100644 index 0000000..0da12e3 --- /dev/null +++ b/crates/typedialog-core/src/form_parser.rs @@ -0,0 +1,1292 @@ +//! TOML form parser and executor +//! +//! Parses form definitions from TOML files and executes them interactively. + +use crate::error::Result; +use crate::prompts; +use crate::i18n::I18nBundle; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, BTreeMap}; +use std::path::Path; + +/// Default order for form elements (auto-assigned based on array position) +fn default_order() -> usize { + 0 +} + +/// Form element (can be a display item or a field) +#[derive(Debug, Clone)] +enum FormElement { + Item(DisplayItem), + Field(FieldDefinition), +} + +/// A display item (header, section, CTA, footer, etc.) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisplayItem { + /// Item name/identifier (not displayed) + pub name: String, + /// Item type/purpose + #[serde(rename = "type")] + pub item_type: String, + /// Content to display (can be literal text or i18n key) + pub content: Option, + /// Optional title (can be literal text or i18n key) + pub title: Option, + /// Optional template expression (alternative to content, e.g., "Welcome {{ env.USER }}!") + pub template: Option, + /// Show border on top + pub border_top: Option, + /// Show border on bottom + pub border_bottom: Option, + /// Left margin (number of spaces) - applies to all content + pub margin_left: Option, + /// Left margin for border lines (overrides margin_left for borders) + pub border_margin_left: Option, + /// Left margin for content/title (overrides margin_left for content) + pub content_margin_left: Option, + /// Optional alignment (left, center, right) + pub align: Option, + /// Optional conditional display (e.g., "role == admin") + pub when: Option, + /// Optional group name (items in same group are associated/grouped together) + pub group: Option, + /// Optional array of file paths to include (for type="group") + pub includes: Option>, + /// Display order (position in form flow) + #[serde(default = "default_order")] + pub order: usize, + /// Character to use for top border (default: "═") + pub border_top_char: Option, + /// Length of top border line (default: 60) + pub border_top_len: Option, + /// Character for top-left corner (default: none) + pub border_top_l: Option, + /// Character for top-right corner (default: none) + pub border_top_r: Option, + /// Character to use for bottom border (default: "═") + pub border_bottom_char: Option, + /// Length of bottom border line (default: 60) + pub border_bottom_len: Option, + /// Character for bottom-left corner (default: none) + pub border_bottom_l: Option, + /// Character for bottom-right corner (default: none) + pub border_bottom_r: Option, + /// Optional flag indicating if content/title are i18n keys + pub i18n: Option, +} + +impl DisplayItem { + /// Check if this item should be displayed (any non-empty visible attribute) + fn should_display(&self) -> bool { + self.content.as_deref().is_some_and(|c| !c.is_empty()) + || self.title.as_deref().is_some_and(|t| !t.is_empty()) + || self.border_top.unwrap_or(false) + || self.border_bottom.unwrap_or(false) + } + + /// Render the display item with formatting, respecting conditionals + pub fn render(&self, results: &HashMap) { + // Check if item should be shown based on conditional + if let Some(condition) = &self.when { + if !evaluate_condition(condition, results) { + // Item condition not met, skip it + return; + } + } + + if !self.should_display() { + return; + } + + let default_margin = self.margin_left.unwrap_or(0); + let border_margin = self.border_margin_left.unwrap_or(default_margin); + let content_margin = self.content_margin_left.unwrap_or(default_margin); + + let border_margin_str = " ".repeat(border_margin); + let content_margin_str = " ".repeat(content_margin); + + // Top border line + if self.border_top.unwrap_or(false) { + let top_l = self.border_top_l.as_deref().unwrap_or(""); + let top_char = self.border_top_char.as_deref().unwrap_or("═"); + let top_len = self.border_top_len.unwrap_or(60); + let top_r = self.border_top_r.as_deref().unwrap_or(""); + let top_border = top_char.repeat(top_len); + println!("{}{}{}{}", border_margin_str, top_l, top_border, top_r); + } + + // Title + if let Some(title) = &self.title { + if !title.is_empty() { + println!("{}{}", content_margin_str, title); + } + } + + // Content + if let Some(content) = &self.content { + if !content.is_empty() { + for line in content.lines() { + println!("{}{}", content_margin_str, line); + } + } + } + + // Bottom border line + if self.border_bottom.unwrap_or(false) { + let bottom_l = self.border_bottom_l.as_deref().unwrap_or(""); + let bottom_char = self.border_bottom_char.as_deref().unwrap_or("═"); + let bottom_len = self.border_bottom_len.unwrap_or(60); + let bottom_r = self.border_bottom_r.as_deref().unwrap_or(""); + let bottom_border = bottom_char.repeat(bottom_len); + println!("{}{}{}{}", border_margin_str, bottom_l, bottom_border, bottom_r); + } + } +} + +/// A complete form definition loaded from TOML +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormDefinition { + /// Form name/identifier + pub name: String, + /// Optional form description + pub description: Option, + /// Optional locale override for this form (e.g., "es-ES", "en-US") + pub locale: Option, + /// Optional template for pre-processing form (generates prompts dynamically) + pub template: Option, + /// Optional path to template for post-processing results + pub output_template: Option, + /// Optional i18n prefix for message keys (e.g., "forms.registration") + pub i18n_prefix: Option, + /// Display mode: Complete (all fields at once) or FieldByField (one at a time) + #[serde(default)] + pub display_mode: DisplayMode, + /// Array of display items (headers, sections, CTAs, etc.) + #[serde(default)] + pub items: Vec, + /// Array of form fields + #[serde(default)] + pub fields: Vec, +} + +/// A single field in a form +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldDefinition { + /// Field name (becomes the result key) + pub name: String, + /// Field input type + #[serde(rename = "type")] + pub field_type: FieldType, + /// Prompt message (can be literal text or i18n key) + pub prompt: String, + /// Optional default value (can contain template expressions like {{ env.USER }}) + pub default: Option, + /// Optional placeholder text (can be literal text or i18n key) + pub placeholder: Option, + /// Optional options list (can contain literal text or i18n keys) + pub options: Option>, + /// Optional field requirement flag + pub required: Option, + /// Optional file extension (for editor) + pub file_extension: Option, + /// Optional prefix text (for editor) + pub prefix_text: Option, + /// Optional page size (for select/multiselect) + pub page_size: Option, + /// Optional vim mode flag (for select/multiselect) + pub vim_mode: Option, + /// Optional custom type name (for custom) + pub custom_type: Option, + /// Optional min date (for date) + pub min_date: Option, + /// Optional max date (for date) + pub max_date: Option, + /// Optional week start day (for date, default: Mon) + pub week_start: Option, + /// Display order (position in form flow) + #[serde(default = "default_order")] + pub order: usize, + /// Optional conditional display (e.g., "role == admin", "country != US") + pub when: Option, + /// Optional flag indicating if prompt/placeholder/options are i18n keys + pub i18n: Option, + /// Optional semantic grouping for form organization + #[serde(default)] + pub group: Option, + /// Nickel contract/predicate (e.g., "String | std.string.NonEmpty") + #[serde(default)] + pub nickel_contract: Option, + /// Original Nickel field path (e.g., ["user", "name"]) + #[serde(default)] + pub nickel_path: Option>, + /// Original Nickel documentation + #[serde(default)] + pub nickel_doc: Option, +} + +/// Supported field input types +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FieldType { + /// Single-line text input + Text, + /// Yes/no confirmation + Confirm, + /// Single selection from list + Select, + /// Multiple selection from list + MultiSelect, + /// Secure password input + Password, + /// Custom type parsing + Custom, + /// External editor + Editor, + /// Date selection + Date, +} + +/// Form display mode - how fields are presented to user +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DisplayMode { + /// Show all fields at once (complete form) - default for TUI and Web + #[default] + #[serde(alias = "complete", alias = "all")] + Complete, + /// Show one field at a time (step by step) + #[serde(alias = "step")] + FieldByField, +} + +/// Parse TOML string into a FormDefinition +pub fn parse_toml(content: &str) -> Result { + toml::from_str(content).map_err(|e| e.into()) +} + +/// Load form from TOML file and execute with proper path resolution +pub fn load_and_execute_from_file(path: impl AsRef) -> Result> { + let path_ref = path.as_ref(); + let content = std::fs::read_to_string(path_ref)?; + let form = parse_toml(&content)?; + + // Get the directory of the current file for relative path resolution + let base_dir = path_ref + .parent() + .unwrap_or_else(|| Path::new(".")); + + execute_with_base_dir(form, base_dir) +} + +/// Load form from TOML file (returns FormDefinition, doesn't execute) +pub fn load_from_file(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path)?; + parse_toml(&content) +} + +/// Load items from a TOML file with proper path resolution +fn load_items_from_file(path: &str, base_dir: &Path) -> Result> { + let resolved_path = if Path::new(path).is_absolute() { + Path::new(path).to_path_buf() + } else { + base_dir.join(path) + }; + let content = std::fs::read_to_string(&resolved_path)?; + let form: FormDefinition = toml::from_str(&content)?; + Ok(form.items) +} + +/// Load fields from a TOML file with proper path resolution +fn load_fields_from_file(path: &str, base_dir: &Path) -> Result> { + let resolved_path = if Path::new(path).is_absolute() { + Path::new(path).to_path_buf() + } else { + base_dir.join(path) + }; + let content = std::fs::read_to_string(&resolved_path)?; + let form: FormDefinition = toml::from_str(&content)?; + Ok(form.fields) +} + +/// Evaluate a conditional expression against previous results +/// Supports formats like: +/// - "field_name == value" +/// - "field_name != value" +/// - "field_name contains value" +/// - "field_name startswith value" +pub fn evaluate_condition(condition: &str, results: &HashMap) -> bool { + let condition = condition.trim(); + + // Check string operators first (word boundaries) + let string_operators = ["contains", "startswith", "endswith"]; + for op_str in &string_operators { + if let Some(pos) = condition.find(op_str) { + // Make sure it's word-bounded (not part of another word) + let before_ok = pos == 0 || !condition[..pos].chars().last().unwrap_or(' ').is_alphanumeric(); + let after_ok = pos + op_str.len() >= condition.len() || + !condition[pos + op_str.len()..].chars().next().unwrap_or(' ').is_alphanumeric(); + + if before_ok && after_ok { + let left = condition[..pos].trim(); + let right = condition[pos + op_str.len()..].trim(); + + let field_value = results.get(left).cloned().unwrap_or(serde_json::Value::Null); + let field_str = value_to_string(&field_value); + let expected = parse_condition_value(right); + let expected_str = value_to_string(&expected); + + match *op_str { + "contains" => return field_str.contains(&expected_str), + "startswith" => return field_str.starts_with(&expected_str), + "endswith" => return field_str.ends_with(&expected_str), + _ => {} + } + } + } + } + + // Parse numeric/comparison operators in order of precedence (longest first to avoid partial matches) + let operators = [ + ("<=", "le"), (">=", "ge"), ("==", "eq"), ("!=", "ne"), + (">", "gt"), ("<", "lt"), + ]; + + for (op_str, _op_name) in &operators { + if let Some(pos) = condition.find(op_str) { + let left = condition[..pos].trim(); + let right = condition[pos + op_str.len()..].trim(); + + // Get the field value from results + let field_value = results.get(left).cloned().unwrap_or(serde_json::Value::Null); + + // Parse the right side as value (handle quoted strings and raw values) + let expected = parse_condition_value(right); + + // Perform comparison + match *op_str { + "==" => return values_equal(&field_value, &expected), + "!=" => return !values_equal(&field_value, &expected), + ">" => return compare_values(&field_value, &expected) == std::cmp::Ordering::Greater, + "<" => return compare_values(&field_value, &expected) == std::cmp::Ordering::Less, + ">=" => { + let cmp = compare_values(&field_value, &expected); + return cmp == std::cmp::Ordering::Greater || cmp == std::cmp::Ordering::Equal; + } + "<=" => { + let cmp = compare_values(&field_value, &expected); + return cmp == std::cmp::Ordering::Less || cmp == std::cmp::Ordering::Equal; + } + _ => {} + } + } + } + + // If no valid condition found, default to true + true +} + +/// Parse a value from condition right-hand side +fn parse_condition_value(s: &str) -> serde_json::Value { + let s = s.trim(); + + // Remove quotes if present + if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) { + return serde_json::json!(s[1..s.len() - 1].to_string()); + } + + // Try to parse as number + if let Ok(n) = s.parse::() { + return serde_json::json!(n); + } + if let Ok(n) = s.parse::() { + return serde_json::json!(n); + } + + // Parse as boolean + match s.to_lowercase().as_str() { + "true" | "yes" | "1" => serde_json::json!(true), + "false" | "no" | "0" => serde_json::json!(false), + _ => serde_json::json!(s.to_string()), // Default to string + } +} + +/// Compare two values for equality, handling different types +fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool { + match (a, b) { + (serde_json::Value::String(s1), serde_json::Value::String(s2)) => s1 == s2, + (serde_json::Value::Number(n1), serde_json::Value::Number(n2)) => n1 == n2, + (serde_json::Value::Bool(b1), serde_json::Value::Bool(b2)) => b1 == b2, + (serde_json::Value::Null, serde_json::Value::Null) => true, + // Try numeric comparison if one is string and other is number + (serde_json::Value::String(s), serde_json::Value::Number(n)) | + (serde_json::Value::Number(n), serde_json::Value::String(s)) => { + if let Ok(parsed) = s.parse::() { + if let Some(num_val) = n.as_f64() { + return (parsed - num_val).abs() < 1e-10; + } + } + false + } + // String to bool comparison + (serde_json::Value::String(s), serde_json::Value::Bool(b)) | + (serde_json::Value::Bool(b), serde_json::Value::String(s)) => { + matches!((s.to_lowercase().as_str(), b), + ("true" | "yes" | "1", true) | ("false" | "no" | "0", false)) + } + _ => false, + } +} + +/// Compare two values numerically +fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> std::cmp::Ordering { + use std::cmp::Ordering; + + // Extract numeric values + let a_num = extract_numeric(a); + let b_num = extract_numeric(b); + + match (a_num, b_num) { + (Some(an), Some(bn)) => { + if (an - bn).abs() < 1e-10 { + Ordering::Equal + } else if an > bn { + Ordering::Greater + } else { + Ordering::Less + } + } + _ => { + // Fall back to string comparison + let a_str = value_to_string(a); + let b_str = value_to_string(b); + a_str.cmp(&b_str) + } + } +} + +/// Extract numeric value from JSON value +fn extract_numeric(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Number(n) => n.as_f64(), + serde_json::Value::String(s) => s.parse::().ok(), + serde_json::Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + } +} + +/// Convert JSON value to string for comparison +fn value_to_string(v: &serde_json::Value) -> String { + 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(), + } +} + +/// Execute a form with base directory for path resolution +fn execute_with_base_dir(form: FormDefinition, base_dir: &Path) -> Result> { + let mut results = HashMap::new(); + + // Print form header + if let Some(desc) = &form.description { + println!("\n{}\n{}\n", form.name, desc); + } else { + println!("\n{}\n", form.name); + } + + // Expand groups with includes and build ordered element map + let mut element_map: BTreeMap = BTreeMap::new(); + let mut order_counter = 0; + + // Process items (expand groups and assign order if not specified) + for item in form.items.iter() { + let mut item_clone = item.clone(); + + // Handle group type with includes + if item.item_type == "group" { + let group_order = item.order; + let group_condition = item.when.clone(); // Capture group's when condition + if let Some(includes) = &item.includes { + // Load items and fields from included files + // Use group_order * 100 + relative_order to avoid collisions + let mut group_item_counter = 1; + + for include_path in includes { + // Try loading items first + match load_items_from_file(include_path, base_dir) { + Ok(loaded_items) => { + for mut loaded_item in loaded_items { + // Propagate group's when condition to loaded items if group has a condition + if let Some(ref condition) = group_condition { + if loaded_item.when.is_none() { + loaded_item.when = Some(condition.clone()); + } + } + // Adjust order: use group_order as base (multiplied by 100) + // plus item's relative order from fragment + let relative_order = if loaded_item.order > 0 { + loaded_item.order + } else { + group_item_counter + }; + loaded_item.order = group_order * 100 + relative_order; + group_item_counter += 1; + element_map.insert(loaded_item.order, FormElement::Item(loaded_item)); + } + } + Err(e) => { + println!("❌ ERROR: Failed to load include '{}': {}", include_path, e); + return Err(e); + } + } + // Try loading fields + match load_fields_from_file(include_path, base_dir) { + Ok(loaded_fields) => { + for mut loaded_field in loaded_fields { + // Propagate group's when condition to loaded fields if group has a condition + if let Some(ref condition) = group_condition { + if loaded_field.when.is_none() { + loaded_field.when = Some(condition.clone()); + } + } + // Same approach for fields + let relative_order = if loaded_field.order > 0 { + loaded_field.order + } else { + group_item_counter + }; + loaded_field.order = group_order * 100 + relative_order; + group_item_counter += 1; + element_map.insert(loaded_field.order, FormElement::Field(loaded_field)); + } + } + Err(_e) => { + // Fields might not exist in this file, that's ok + } + } + } + } + // Don't add group item itself to the map + } else { + // Regular item + if item_clone.order == 0 { + item_clone.order = order_counter; + order_counter += 1; + } + element_map.insert(item_clone.order, FormElement::Item(item_clone)); + } + } + + // Add form fields to the element map + for field in form.fields.clone() { + let mut field_clone = field.clone(); + if field_clone.order == 0 { + field_clone.order = order_counter; + order_counter += 1; + } + element_map.insert(field_clone.order, FormElement::Field(field_clone)); + } + + // Process elements in order + for (_, element) in element_map.iter() { + match element { + FormElement::Item(item) => { + item.render(&results); + } + FormElement::Field(field) => { + // Check if field should be shown based on conditional + if let Some(condition) = &field.when { + if !evaluate_condition(condition, &results) { + // Field condition not met, skip it + println!("⊘ Skipping '{}' (condition not met)\n", field.name); + continue; + } + } + + let value = execute_field(field, &results)?; + results.insert(field.name.clone(), value.clone()); + } + } + } + + Ok(results) +} + +/// Execute a form and collect results (no path resolution - for backwards compatibility) +pub fn execute(form: FormDefinition) -> Result> { + execute_with_base_dir(form, Path::new(".")) +} + +/// Execute a single field +fn execute_field(field: &FieldDefinition, _previous_results: &HashMap) -> Result { + 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 execute_field(field, _previous_results); // Retry + } + 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 execute_field(field, _previous_results); // Retry + } + 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 execute_field(field, _previous_results); // Retry + } + 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 execute_field(field, _previous_results); // Retry + } + 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 execute_field(field, _previous_results); // Retry + } + Ok(serde_json::json!(result)) + } + } +} + +/// Translate a DisplayItem if i18n is enabled +fn translate_display_item(item: &DisplayItem, bundle: Option<&I18nBundle>) -> DisplayItem { + if item.i18n.unwrap_or(false) { + if let Some(bundle) = bundle { + let mut translated = item.clone(); + if let Some(content) = &item.content { + translated.content = Some(bundle.translate_if_key(content, None)); + } + if let Some(title) = &item.title { + translated.title = Some(bundle.translate_if_key(title, None)); + } + return translated; + } + } + item.clone() +} + +/// Translate a FieldDefinition if i18n is enabled +fn translate_field_definition(field: &FieldDefinition, bundle: Option<&I18nBundle>) -> FieldDefinition { + if field.i18n.unwrap_or(false) { + if let Some(bundle) = bundle { + let mut translated = field.clone(); + translated.prompt = bundle.translate_if_key(&field.prompt, None); + if let Some(placeholder) = &field.placeholder { + translated.placeholder = Some(bundle.translate_if_key(placeholder, None)); + } + if let Some(options) = &field.options { + translated.options = Some( + options + .iter() + .map(|opt| bundle.translate_if_key(opt, None)) + .collect(), + ); + } + return translated; + } + } + field.clone() +} + +/// Execute a form using a specific backend +/// +/// This is the primary async form execution function that integrates +/// with the FormBackend trait abstraction, enabling support for multiple +/// rendering backends (CLI, TUI, Web). +/// +/// # Arguments +/// +/// * `form` - The parsed form definition +/// * `backend` - A mutable reference to the form backend implementation +/// * `i18n_bundle` - Optional I18n bundle for translating form content +/// +/// # Returns +/// +/// A HashMap containing all field results (name -> value) +pub async fn execute_with_backend_i18n( + form: FormDefinition, + backend: &mut dyn crate::backends::FormBackend, + i18n_bundle: Option<&I18nBundle>, +) -> Result> { + use crate::backends::RenderContext; + + let mut results = HashMap::new(); + + // Initialize backend + backend.initialize().await?; + + // Print form header + if let Some(desc) = &form.description { + println!("\n{}\n{}\n", form.name, desc); + } else { + println!("\n{}\n", form.name); + } + + // Create render context + let mut context = RenderContext { + results: results.clone(), + locale: None, + }; + + // Expand groups with includes and build ordered element map + let mut element_map: BTreeMap = BTreeMap::new(); + let mut order_counter = 0; + + // Process items (expand groups and assign order if not specified) + for item in form.items.iter() { + let mut item_clone = item.clone(); + + // Handle group type with includes + if item.item_type == "group" { + let group_order = item.order; + let group_condition = item.when.clone(); + if let Some(includes) = &item.includes { + let mut group_item_counter = 1; + + for include_path in includes { + match load_items_from_file(include_path, Path::new(".")) { + Ok(loaded_items) => { + for mut loaded_item in loaded_items { + if let Some(ref condition) = group_condition { + if loaded_item.when.is_none() { + loaded_item.when = Some(condition.clone()); + } + } + let relative_order = if loaded_item.order > 0 { + loaded_item.order + } else { + group_item_counter + }; + loaded_item.order = group_order * 100 + relative_order; + group_item_counter += 1; + element_map.insert(loaded_item.order, FormElement::Item(loaded_item)); + } + } + Err(_) => { + if let Ok(loaded_fields) = load_fields_from_file(include_path, Path::new(".")) { + for mut loaded_field in loaded_fields { + if let Some(ref condition) = group_condition { + if loaded_field.when.is_none() { + loaded_field.when = Some(condition.clone()); + } + } + let relative_order = if loaded_field.order > 0 { + loaded_field.order + } else { + order_counter + }; + loaded_field.order = group_order * 100 + relative_order; + order_counter += 1; + element_map.insert(loaded_field.order, FormElement::Field(loaded_field)); + } + } + } + } + } + } + } else { + item_clone.order = if item_clone.order > 0 { + item_clone.order + } else { + order_counter + }; + order_counter += 1; + element_map.insert(item_clone.order, FormElement::Item(item_clone)); + } + } + + // Process fields and assign order if not specified + for field in form.fields.iter() { + let mut field_clone = field.clone(); + field_clone.order = if field_clone.order > 0 { + field_clone.order + } else { + order_counter + }; + order_counter += 1; + element_map.insert(field_clone.order, FormElement::Field(field_clone)); + } + + // Check display mode and execute accordingly + if form.display_mode == DisplayMode::Complete { + // Complete mode: show all fields at once + let items: Vec<&DisplayItem> = element_map + .values() + .filter_map(|e| match e { + FormElement::Item(item) => { + // Check conditional for items + if let Some(condition) = &item.when { + if evaluate_condition(condition, &results) { + Some(item) + } else { + None + } + } else { + Some(item) + } + } + _ => None, + }) + .collect(); + + let fields: Vec<&FieldDefinition> = element_map + .values() + .filter_map(|e| match e { + FormElement::Field(field) => { + // Check conditional for fields + if let Some(condition) = &field.when { + if evaluate_condition(condition, &results) { + Some(field) + } else { + None + } + } else { + Some(field) + } + } + _ => None, + }) + .collect(); + + // Convert borrowed references to owned for the backend call + // Also apply i18n translations if enabled + let items_owned: Vec = items.iter().map(|i| translate_display_item(i, i18n_bundle)).collect(); + let fields_owned: Vec = fields.iter().map(|f| translate_field_definition(f, i18n_bundle)).collect(); + + results = backend.execute_form_complete(&form, &items_owned, &fields_owned).await?; + } else { + // Field-by-field mode (original behavior) + for (_, element) in element_map.iter() { + match element { + FormElement::Item(item) => { + // Update context results before rendering + context.results = results.clone(); + let translated_item = translate_display_item(item, i18n_bundle); + backend.render_display_item(&translated_item, &context).await?; + } + FormElement::Field(field) => { + // Check if field should be shown based on conditional + if let Some(condition) = &field.when { + if !evaluate_condition(condition, &results) { + println!("⊘ Skipping '{}' (condition not met)\n", field.name); + continue; + } + } + + // Update context before executing field + context.results = results.clone(); + let translated_field = translate_field_definition(field, i18n_bundle); + let value = backend.execute_field(&translated_field, &context).await?; + results.insert(field.name.clone(), value); + } + } + } + } + + // Shutdown backend + backend.shutdown().await?; + + Ok(results) +} + +/// Execute a form using a specific backend (backward compatible wrapper) +/// +/// This is a convenience wrapper around `execute_with_backend_i18n` that +/// doesn't use i18n translation. For i18n support, use `execute_with_backend_i18n`. +/// +/// # Arguments +/// +/// * `form` - The parsed form definition +/// * `backend` - A mutable reference to the form backend implementation +/// +/// # Returns +/// +/// A HashMap containing all field results (name -> value) +pub async fn execute_with_backend( + form: FormDefinition, + backend: &mut dyn crate::backends::FormBackend, +) -> Result> { + execute_with_backend_i18n(form, backend, None).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_form() { + let toml = r#" + name = "test_form" + description = "A test form" + + [[fields]] + name = "username" + type = "text" + prompt = "Enter username" + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.name, "test_form"); + assert_eq!(form.fields.len(), 1); + assert_eq!(form.fields[0].name, "username"); + } + + #[test] + fn test_parse_form_with_options() { + let toml = r#" + name = "choice_form" + + [[fields]] + name = "role" + type = "select" + prompt = "Select role" + options = ["Admin", "User", "Guest"] + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.fields[0].options.as_ref().unwrap().len(), 3); + } + + #[test] + fn test_field_types() { + assert_eq!( + serde_json::from_str::("\"text\"").unwrap(), + FieldType::Text + ); + assert_eq!( + serde_json::from_str::("\"confirm\"").unwrap(), + FieldType::Confirm + ); + assert_eq!( + serde_json::from_str::("\"select\"").unwrap(), + FieldType::Select + ); + } + + #[test] + fn test_evaluate_condition_equals() { + let mut results = HashMap::new(); + results.insert("role".to_string(), serde_json::json!("admin")); + + assert!(evaluate_condition("role == admin", &results)); + assert!(!evaluate_condition("role == user", &results)); + } + + #[test] + fn test_evaluate_condition_not_equals() { + let mut results = HashMap::new(); + results.insert("country".to_string(), serde_json::json!("US")); + + assert!(evaluate_condition("country != UK", &results)); + assert!(!evaluate_condition("country != US", &results)); + } + + #[test] + fn test_evaluate_condition_contains() { + let mut results = HashMap::new(); + results.insert("email".to_string(), serde_json::json!("alice@company.com")); + + assert!(evaluate_condition("email contains company", &results)); + assert!(!evaluate_condition("email contains gmail", &results)); + } + + #[test] + fn test_evaluate_condition_startswith() { + let mut results = HashMap::new(); + results.insert("username".to_string(), serde_json::json!("admin_user")); + + assert!(evaluate_condition("username startswith admin", &results)); + assert!(!evaluate_condition("username startswith user", &results)); + } + + #[test] + fn test_parse_form_with_conditionals() { + let toml = r#" + name = "conditional_form" + + [[fields]] + name = "role" + type = "select" + prompt = "Select role" + options = ["Admin", "User", "Guest"] + required = true + + [[fields]] + name = "admin_password" + type = "password" + prompt = "Enter admin password" + when = "role == Admin" + required = true + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.fields.len(), 2); + assert_eq!(form.fields[0].name, "role"); + assert_eq!(form.fields[1].name, "admin_password"); + assert_eq!(form.fields[1].when.as_deref(), Some("role == Admin")); + assert_eq!(form.fields[1].required, Some(true)); + } + + #[test] + fn test_parse_form_with_display_items() { + let toml = r#" + name = "form_with_items" + + [[items]] + name = "header" + type = "header" + title = "Welcome to Registration" + border_top = true + border_bottom = true + + [[items]] + name = "info_section" + type = "section" + content = "Please fill in your information below" + + [[fields]] + name = "email" + type = "text" + prompt = "Email" + + [[items]] + name = "cta" + type = "cta" + title = "Ready to submit?" + align = "center" + border_top = true + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.items.len(), 3); + assert_eq!(form.items[0].name, "header"); + assert_eq!(form.items[0].title.as_deref(), Some("Welcome to Registration")); + assert_eq!(form.items[0].border_top, Some(true)); + + assert_eq!(form.items[1].name, "info_section"); + assert_eq!(form.items[1].content.as_deref(), Some("Please fill in your information below")); + + assert_eq!(form.items[2].name, "cta"); + assert_eq!(form.items[2].align.as_deref(), Some("center")); + + assert_eq!(form.fields.len(), 1); + assert_eq!(form.fields[0].name, "email"); + } + + #[test] + fn test_display_items_with_conditionals() { + let toml = r#" + name = "conditional_items" + + [[items]] + name = "header" + type = "header" + title = "Account Setup" + border_top = true + border_bottom = true + + [[fields]] + name = "account_type" + type = "select" + prompt = "Account type" + options = ["Personal", "Business"] + required = true + + [[items]] + name = "business_header" + type = "section" + title = "Business Information" + when = "account_type == Business" + border_top = true + + [[fields]] + name = "company_name" + type = "text" + prompt = "Company name" + when = "account_type == Business" + required = true + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.items.len(), 2); + + // Check that display item has conditional + assert_eq!(form.items[1].name, "business_header"); + assert_eq!(form.items[1].when.as_deref(), Some("account_type == Business")); + assert_eq!(form.items[1].title.as_deref(), Some("Business Information")); + } + + #[test] + fn test_display_items_with_groups() { + let toml = r#" + name = "grouped_items" + + [[items]] + name = "header" + type = "header" + title = "Main" + group = "main" + + [[items]] + name = "premium_header" + type = "section" + title = "Premium" + group = "premium" + when = "account == premium" + + [[items]] + name = "premium_features" + type = "section" + content = "Features" + group = "premium" + when = "account == premium" + + [[fields]] + name = "account" + type = "select" + prompt = "Account" + options = ["free", "premium"] + "#; + + let form = parse_toml(toml).unwrap(); + assert_eq!(form.items.len(), 3); + + // Check groups + assert_eq!(form.items[0].group.as_deref(), Some("main")); + assert_eq!(form.items[1].group.as_deref(), Some("premium")); + assert_eq!(form.items[2].group.as_deref(), Some("premium")); + + // Check that grouped items can also have conditionals + assert_eq!(form.items[1].when.as_deref(), Some("account == premium")); + assert_eq!(form.items[2].when.as_deref(), Some("account == premium")); + } + + #[test] + fn test_group_type_with_includes() { + let toml = r#" + name = "form_with_groups" + + [[items]] + name = "main_group" + type = "group" + includes = ["examples/fragments/header.toml"] + + [[fields]] + name = "plan" + type = "select" + prompt = "Plan" + options = ["Premium", "Enterprise"] + + [[items]] + name = "premium_group" + type = "group" + when = "plan == Premium" + includes = ["examples/fragments/premium_section.toml"] + + [[items]] + name = "enterprise_group" + type = "group" + when = "plan == Enterprise" + includes = ["examples/fragments/enterprise_section.toml"] + "#; + + let form = parse_toml(toml).unwrap(); + + // Check that groups are parsed correctly + assert_eq!(form.items.len(), 3); + + // Check first group (main_group) + assert_eq!(form.items[0].name, "main_group"); + assert_eq!(form.items[0].item_type, "group"); + assert_eq!(form.items[0].includes.as_ref().map(|v| v.len()), Some(1)); + + // Check conditional groups + assert_eq!(form.items[1].when.as_deref(), Some("plan == Premium")); + assert_eq!(form.items[2].when.as_deref(), Some("plan == Enterprise")); + } +} diff --git a/crates/typedialog-core/src/helpers.rs b/crates/typedialog-core/src/helpers.rs new file mode 100644 index 0000000..600ee18 --- /dev/null +++ b/crates/typedialog-core/src/helpers.rs @@ -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, + format: &str, +) -> crate::error::Result { + 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 = arr.iter().map(format_value).collect(); + format!("[{}]", items.join(", ")) + } + Value::Object(obj) => { + let items: Vec = 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) -> Value { + json!(results) +} + +/// Convert results to JSON string +pub fn to_json_string(results: &HashMap) -> crate::error::Result { + 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")); + } +} diff --git a/crates/typedialog-core/src/i18n/loader.rs b/crates/typedialog-core/src/i18n/loader.rs new file mode 100644 index 0000000..7226bbb --- /dev/null +++ b/crates/typedialog-core/src/i18n/loader.rs @@ -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> { + 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> { + 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::>(&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, prefix: &str) -> HashMap { + 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()) + ); + } +} diff --git a/crates/typedialog-core/src/i18n/mod.rs b/crates/typedialog-core/src/i18n/mod.rs new file mode 100644 index 0000000..d776f70 --- /dev/null +++ b/crates/typedialog-core/src/i18n/mod.rs @@ -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, + fallback_bundle: FluentBundle, + toml_translations: HashMap, +} + +impl I18nBundle { + /// Create a new I18nBundle from a locale and fallback locale + pub fn new( + locale: LanguageIdentifier, + fallback_locale: LanguageIdentifier, + loader: &LocaleLoader, + ) -> Result { + 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> { + 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")); + } + } +} diff --git a/crates/typedialog-core/src/i18n/resolver.rs b/crates/typedialog-core/src/i18n/resolver.rs new file mode 100644 index 0000000..51a083c --- /dev/null +++ b/crates/typedialog-core/src/i18n/resolver.rs @@ -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 { + // 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(); + } +} diff --git a/crates/typedialog-core/src/lib.rs b/crates/typedialog-core/src/lib.rs new file mode 100644 index 0000000..3a360bb --- /dev/null +++ b/crates/typedialog-core/src/lib.rs @@ -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>(()) +//! ``` +//! +//! # Quick Start with Backends +//! +//! ```ignore +//! use typedialog_core::backends::{BackendFactory, BackendType}; +//! use typedialog_core::form_parser; +//! +//! async fn example() -> Result<(), Box> { +//! 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()); + } +} diff --git a/crates/typedialog-core/src/nickel/cli.rs b/crates/typedialog-core/src/nickel/cli.rs new file mode 100644 index 0000000..e833b2f --- /dev/null +++ b/crates/typedialog-core/src/nickel/cli.rs @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/crates/typedialog-core/src/nickel/contracts.rs b/crates/typedialog-core/src/nickel/contracts.rs new file mode 100644 index 0000000..e412090 --- /dev/null +++ b/crates/typedialog-core/src/nickel/contracts.rs @@ -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 { + 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::().ok()?; + let b = parts[1].parse::().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()); + } +} diff --git a/crates/typedialog-core/src/nickel/mod.rs b/crates/typedialog-core/src/nickel/mod.rs new file mode 100644 index 0000000..ed47ae1 --- /dev/null +++ b/crates/typedialog-core/src/nickel/mod.rs @@ -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; diff --git a/crates/typedialog-core/src/nickel/parser.rs b/crates/typedialog-core/src/nickel/parser.rs new file mode 100644 index 0000000..8119ffa --- /dev/null +++ b/crates/typedialog-core/src/nickel/parser.rs @@ -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 { + 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, + path: Vec, + fields: &mut Vec, + ) -> 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, + ) -> Result { + 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 { + 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 { + 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 { + 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))); + } +} diff --git a/crates/typedialog-core/src/nickel/schema_ir.rs b/crates/typedialog-core/src/nickel/schema_ir.rs new file mode 100644 index 0000000..21a17e2 --- /dev/null +++ b/crates/typedialog-core/src/nickel/schema_ir.rs @@ -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, + + /// All fields in the schema + pub fields: Vec, +} + +/// 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, + + /// Flattened name: "user_name" for user.name + pub flat_name: String, + + /// Nickel type information + pub nickel_type: NickelType, + + /// Documentation from | doc + pub doc: Option, + + /// Default value from | default + pub default: Option, + + /// Whether field is optional (| optional) + pub optional: bool, + + /// Nickel contract/predicate (e.g., "std.string.NonEmpty") + pub contract: Option, + + /// Semantic grouping for form UI + pub group: Option, +} + +/// 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), + + /// Nested record with fields + Record(Vec), + + /// Custom/unknown type + Custom(String), +} + +impl NickelSchemaIR { + /// Create a new schema with given name and fields + pub fn new(name: String, fields: Vec) -> 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 { + let mut groups: Vec<_> = self + .fields + .iter() + .filter_map(|f| f.group.clone()) + .collect(); + groups.sort(); + groups.dedup(); + groups + } +} diff --git a/crates/typedialog-core/src/nickel/serializer.rs b/crates/typedialog-core/src/nickel/serializer.rs new file mode 100644 index 0000000..ddaa382 --- /dev/null +++ b/crates/typedialog-core/src/nickel/serializer.rs @@ -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, + schema: &NickelSchemaIR, + ) -> Result { + // 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, + 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 { + 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("]")); + } +} diff --git a/crates/typedialog-core/src/nickel/template_engine.rs b/crates/typedialog-core/src/nickel/template_engine.rs new file mode 100644 index 0000000..49d61ed --- /dev/null +++ b/crates/typedialog-core/src/nickel/template_engine.rs @@ -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, + ) -> Result { + #[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, + ) -> Result { + #[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")); + } +} diff --git a/crates/typedialog-core/src/nickel/toml_generator.rs b/crates/typedialog-core/src/nickel/toml_generator.rs new file mode 100644 index 0000000..b2d9eb8 --- /dev/null +++ b/crates/typedialog-core/src/nickel/toml_generator.rs @@ -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 { + let mut fields = Vec::new(); + let mut items = Vec::new(); + let mut group_order: HashMap = 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::>() { + 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 { + 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)> { + 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> { + // 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 = 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::() + chars.as_str(), + } + }) + .collect::>() + .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::() + chars.as_str(), + } + }) + .collect::>() + .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())); + } +} diff --git a/crates/typedialog-core/src/nickel/types.rs b/crates/typedialog-core/src/nickel/types.rs new file mode 100644 index 0000000..5970c5a --- /dev/null +++ b/crates/typedialog-core/src/nickel/types.rs @@ -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!() + } +} diff --git a/crates/typedialog-core/src/prompts.rs b/crates/typedialog-core/src/prompts.rs new file mode 100644 index 0000000..700004e --- /dev/null +++ b/crates/typedialog-core/src/prompts.rs @@ -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>(()) +/// ``` +pub fn text(prompt: &str, default: Option<&str>, placeholder: Option<&str>) -> Result { + 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>(()) +/// ``` +pub fn confirm(prompt: &str, default: Option, _formatter: Option<&str>) -> Result { + 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>(()) +/// ``` +pub fn password(prompt: &str, with_toggle: bool) -> Result { + 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>(()) +/// ``` +pub fn select( + prompt: &str, + options: Vec, + page_size: Option, + vim_mode: bool, +) -> Result { + 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>(()) +/// ``` +pub fn multi_select( + prompt: &str, + options: Vec, + page_size: Option, + vim_mode: bool, +) -> Result> { + 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>(()) +/// ``` +pub fn editor( + prompt: &str, + file_extension: Option<&str>, + prefix_text: Option<&str>, +) -> Result { + 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() { + "".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>(()) +/// ``` +pub fn date( + prompt: &str, + default: Option<&str>, + min_date: Option<&str>, + max_date: Option<&str>, + week_start: &str, +) -> Result { + 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>(()) +/// ``` +pub fn custom(prompt: &str, type_name: &str, default: Option<&str>) -> Result { + let validator = |input: &str| match type_name { + "i32" => input.parse::().is_ok(), + "i64" => input.parse::().is_ok(), + "u16" => input.parse::().is_ok(), + "u32" => input.parse::().is_ok(), + "u64" => input.parse::().is_ok(), + "f32" => input.parse::().is_ok(), + "f64" => input.parse::().is_ok(), + "ipv4" => input.parse::().is_ok(), + "ipv6" => input.parse::().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 { + 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) -> Result { + 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 { + // 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 { + 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> { + 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 = 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 { + 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 { + // 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 { + 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( + prompt: &str, + type_name: &str, + default: Option<&str>, + validator: &F, +) -> Result +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 = Ok("test".to_string()); + } +} diff --git a/crates/typedialog-core/src/templates/context.rs b/crates/typedialog-core/src/templates/context.rs new file mode 100644 index 0000000..7c64a0c --- /dev/null +++ b/crates/typedialog-core/src/templates/context.rs @@ -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) -> 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(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 + } +} diff --git a/crates/typedialog-core/src/templates/filters.rs b/crates/typedialog-core/src/templates/filters.rs new file mode 100644 index 0000000..e8e1774 --- /dev/null +++ b/crates/typedialog-core/src/templates/filters.rs @@ -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) -> TeraResult { + 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) -> TeraResult { + 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) -> TeraResult { + 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) -> TeraResult { + 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) -> TeraResult { + 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); +} diff --git a/crates/typedialog-core/src/templates/mod.rs b/crates/typedialog-core/src/templates/mod.rs new file mode 100644 index 0000000..e2fd4cc --- /dev/null +++ b/crates/typedialog-core/src/templates/mod.rs @@ -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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/typedialog-tui/Cargo.toml b/crates/typedialog-tui/Cargo.toml new file mode 100644 index 0000000..1957427 --- /dev/null +++ b/crates/typedialog-tui/Cargo.toml @@ -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 diff --git a/crates/typedialog-tui/src/main.rs b/crates/typedialog-tui/src/main.rs new file mode 100644 index 0000000..5046f65 --- /dev/null +++ b/crates/typedialog-tui/src/main.rs @@ -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, + + /// Locale override for form localization + #[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)] + locale: Option, +} + +#[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, cli_locale: &Option) -> 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, + format: &str, + output_file: &Option, +) -> 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(()) +} diff --git a/crates/typedialog-web/Cargo.toml b/crates/typedialog-web/Cargo.toml new file mode 100644 index 0000000..bdda161 --- /dev/null +++ b/crates/typedialog-web/Cargo.toml @@ -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 diff --git a/crates/typedialog-web/src/main.rs b/crates/typedialog-web/src/main.rs new file mode 100644 index 0000000..36a2be9 --- /dev/null +++ b/crates/typedialog-web/src/main.rs @@ -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, +} + +#[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) -> 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(()) +} diff --git a/crates/typedialog/Cargo.toml b/crates/typedialog/Cargo.toml new file mode 100644 index 0000000..33c6351 --- /dev/null +++ b/crates/typedialog/Cargo.toml @@ -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 diff --git a/crates/typedialog/src/main.rs b/crates/typedialog/src/main.rs new file mode 100644 index 0000000..89e586d --- /dev/null +++ b/crates/typedialog/src/main.rs @@ -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, + + /// Locale override for form localization + #[arg(global = true, short, long, help = cli_common::LOCALE_FLAG_HELP)] + locale: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Prompt for text input + Text { + /// Prompt message + prompt: String, + + /// Default value + #[arg(short, long)] + default: Option, + + /// Placeholder text + #[arg(short, long)] + placeholder: Option, + }, + + /// Prompt for confirmation (yes/no) + Confirm { + /// Prompt message + prompt: String, + + /// Default value (true/false) + #[arg(short, long)] + default: Option, + }, + + /// Select a single option from a list + Select { + /// Prompt message + prompt: String, + + /// Options to choose from + options: Vec, + + /// Page size (number of options per page) + #[arg(short, long)] + page_size: Option, + + /// 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, + + /// Page size (number of options per page) + #[arg(short, long)] + page_size: Option, + + /// 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, + }, + + /// Prompt for text using an external editor + Editor { + /// Prompt message + prompt: String, + + /// File extension for the temp file + #[arg(short, long)] + file_extension: Option, + + /// Default content (text or path to file to load) + #[arg(short, long)] + default: Option, + }, + + /// Prompt for date selection using a calendar + Date { + /// Prompt message + prompt: String, + + /// Default date (YYYY-MM-DD format) + #[arg(short, long)] + default: Option, + + /// Minimum date (YYYY-MM-DD format) + #[arg(long)] + min_date: Option, + + /// Maximum date (YYYY-MM-DD format) + #[arg(long)] + max_date: Option, + + /// 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, + }, + + /// 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, + + /// 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, format: &str, output_file: &Option, cli_locale: &Option) -> 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, +) -> 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, + format: &str, + output_file: &Option, +) -> 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, + output: &Option, + 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, + _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 = 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, +) -> Result<()> { + // Load results JSON file + let json_content = fs::read_to_string(&results).map_err(Error::io)?; + let values: HashMap = + 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(()) +}