chore: add crates
This commit is contained in:
parent
96b3ac1279
commit
5a459e7f02
74
Cargo.toml
Normal file
74
Cargo.toml
Normal file
@ -0,0 +1,74 @@
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/typedialog-core",
|
||||
"crates/typedialog",
|
||||
"crates/typedialog-tui",
|
||||
"crates/typedialog-web",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
authors = ["Jesús Pérez <jpl@jesusperez.com>"]
|
||||
edition = "2021"
|
||||
repository = "https://github.com/jesusperezlorenzo/typedialog"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Core serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.9"
|
||||
|
||||
# Utility
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
clap = { version = "4.5", features = ["derive", "cargo"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Templates
|
||||
tera = "1.20"
|
||||
|
||||
# i18n
|
||||
fluent = "0.17"
|
||||
fluent-bundle = "0.16"
|
||||
unic-langid = "0.9"
|
||||
sys-locale = "0.3"
|
||||
dirs = "6.0"
|
||||
|
||||
# Nushell integration
|
||||
nu-protocol = "0.109.1"
|
||||
nu-plugin = "0.109.1"
|
||||
|
||||
# CLI Backend (inquire)
|
||||
inquire = { version = "0.9", features = ["editor", "date"] }
|
||||
dialoguer = "0.12"
|
||||
rpassword = "7.4"
|
||||
|
||||
# TUI Backend (ratatui)
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.29"
|
||||
atty = "0.2"
|
||||
|
||||
# Web Backend (axum)
|
||||
axum = "0.8.7"
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.8", features = ["fs", "cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
# Misc
|
||||
tempfile = "3.23"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = "warn"
|
||||
72
crates/typedialog-core/Cargo.toml
Normal file
72
crates/typedialog-core/Cargo.toml
Normal file
@ -0,0 +1,72 @@
|
||||
[package]
|
||||
name = "typedialog-core"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
description = "Core library for TypeDialog - form handling and multiple rendering backends"
|
||||
|
||||
[lib]
|
||||
name = "typedialog_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
toml.workspace = true
|
||||
chrono.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
async-trait.workspace = true
|
||||
tera = { workspace = true, optional = true }
|
||||
tempfile.workspace = true
|
||||
|
||||
# i18n (optional)
|
||||
fluent = { workspace = true, optional = true }
|
||||
fluent-bundle = { workspace = true, optional = true }
|
||||
unic-langid = { workspace = true, optional = true }
|
||||
sys-locale = { workspace = true, optional = true }
|
||||
dirs = { workspace = true, optional = true }
|
||||
|
||||
# Nushell integration (optional)
|
||||
nu-protocol = { workspace = true, optional = true }
|
||||
nu-plugin = { workspace = true, optional = true }
|
||||
|
||||
# CLI Backend (inquire) - optional
|
||||
inquire = { workspace = true, optional = true }
|
||||
dialoguer = { workspace = true, optional = true }
|
||||
rpassword = { workspace = true, optional = true }
|
||||
|
||||
# TUI Backend (ratatui) - optional
|
||||
ratatui = { workspace = true, optional = true }
|
||||
crossterm = { workspace = true, optional = true }
|
||||
atty = { workspace = true, optional = true }
|
||||
|
||||
# Web Backend (axum) - optional
|
||||
axum = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
tower = { workspace = true, optional = true }
|
||||
tower-http = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["cli", "i18n", "templates"]
|
||||
cli = ["inquire", "dialoguer", "rpassword"]
|
||||
tui = ["ratatui", "crossterm", "atty"]
|
||||
web = ["axum", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", "futures"]
|
||||
i18n = ["fluent", "fluent-bundle", "unic-langid", "sys-locale", "dirs"]
|
||||
templates = ["tera"]
|
||||
nushell = ["nu-protocol", "nu-plugin"]
|
||||
all-backends = ["cli", "tui", "web"]
|
||||
full = ["i18n", "templates", "nushell"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
273
crates/typedialog-core/src/autocompletion.rs
Normal file
273
crates/typedialog-core/src/autocompletion.rs
Normal file
@ -0,0 +1,273 @@
|
||||
//! Autocompletion utilities for interactive prompts
|
||||
//!
|
||||
//! Provides helper classes for:
|
||||
//! 1. **HistoryCompleter** - Remembers and suggests previously entered values
|
||||
//! 2. **FilterCompleter** - Dynamically filters options
|
||||
//! 3. **PatternCompleter** - Provides intelligent suggestions based on patterns
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// History-based completion helper
|
||||
/// Remembers previously entered values and suggests matches
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryCompleter {
|
||||
history: VecDeque<String>,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
impl HistoryCompleter {
|
||||
/// Create a new history completer
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
history: VecDeque::new(),
|
||||
max_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a value to history
|
||||
pub fn add(&mut self, value: String) {
|
||||
if !value.is_empty() {
|
||||
self.history.push_front(value);
|
||||
if self.history.len() > self.max_size {
|
||||
self.history.pop_back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get matching suggestions from history
|
||||
pub fn suggest(&self, partial: &str) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
return self.history.iter().cloned().collect();
|
||||
}
|
||||
|
||||
self.history
|
||||
.iter()
|
||||
.filter(|item| item.to_lowercase().contains(&partial.to_lowercase()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get first completion suggestion
|
||||
pub fn complete(&self, partial: &str) -> Option<String> {
|
||||
if partial.is_empty() {
|
||||
return self.history.front().cloned();
|
||||
}
|
||||
|
||||
self.history
|
||||
.iter()
|
||||
.find(|item| item.starts_with(partial))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get full history
|
||||
pub fn get_history(&self) -> Vec<String> {
|
||||
self.history.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Clear history
|
||||
pub fn clear(&mut self) {
|
||||
self.history.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HistoryCompleter {
|
||||
fn default() -> Self {
|
||||
Self::new(10)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter-based completion helper
|
||||
/// Dynamically filters available options
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FilterCompleter {
|
||||
options: Vec<String>,
|
||||
}
|
||||
|
||||
impl FilterCompleter {
|
||||
/// Create a new filter completer
|
||||
pub fn new(options: Vec<String>) -> Self {
|
||||
Self { options }
|
||||
}
|
||||
|
||||
/// Get filtered options matching the partial input
|
||||
pub fn filter(&self, partial: &str) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
return self.options.clone();
|
||||
}
|
||||
|
||||
self.options
|
||||
.iter()
|
||||
.filter(|opt| opt.to_lowercase().contains(&partial.to_lowercase()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get first matching option
|
||||
pub fn complete(&self, partial: &str) -> Option<String> {
|
||||
if partial.is_empty() {
|
||||
return self.options.first().cloned();
|
||||
}
|
||||
|
||||
self.options
|
||||
.iter()
|
||||
.find(|opt| opt.to_lowercase().starts_with(&partial.to_lowercase()))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern types for intelligent completion
|
||||
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
|
||||
pub enum PatternType {
|
||||
/// Email pattern (suggests @domain)
|
||||
Email,
|
||||
/// URL pattern (suggests http://, https://)
|
||||
Url,
|
||||
/// File path pattern (suggests /, ./, ../)
|
||||
FilePath,
|
||||
/// IPv4 address pattern (suggests octets)
|
||||
IPv4,
|
||||
/// Port number pattern (suggests :)
|
||||
Port,
|
||||
}
|
||||
|
||||
/// Pattern-based completion helper
|
||||
/// Provides intelligent suggestions based on input patterns
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PatternCompleter {
|
||||
pattern: PatternType,
|
||||
suggestions: Vec<String>,
|
||||
}
|
||||
|
||||
impl PatternCompleter {
|
||||
/// Create a new pattern completer
|
||||
pub fn new(pattern: PatternType) -> Self {
|
||||
let suggestions = match pattern {
|
||||
PatternType::Email => vec![
|
||||
"@gmail.com".to_string(),
|
||||
"@outlook.com".to_string(),
|
||||
"@example.com".to_string(),
|
||||
"@company.com".to_string(),
|
||||
],
|
||||
PatternType::Url => vec![
|
||||
"https://".to_string(),
|
||||
"http://".to_string(),
|
||||
"ftp://".to_string(),
|
||||
"ssh://".to_string(),
|
||||
],
|
||||
PatternType::FilePath => vec![
|
||||
"./".to_string(),
|
||||
"../".to_string(),
|
||||
"/".to_string(),
|
||||
"~/".to_string(),
|
||||
],
|
||||
PatternType::IPv4 => vec![
|
||||
"192.168.".to_string(),
|
||||
"10.0.".to_string(),
|
||||
"172.16.".to_string(),
|
||||
"127.0.".to_string(),
|
||||
],
|
||||
PatternType::Port => vec![
|
||||
":8080".to_string(),
|
||||
":3000".to_string(),
|
||||
":5432".to_string(),
|
||||
":80".to_string(),
|
||||
":443".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
Self { pattern, suggestions }
|
||||
}
|
||||
|
||||
/// Add custom suggestion
|
||||
pub fn add_suggestion(&mut self, suggestion: String) {
|
||||
if !self.suggestions.contains(&suggestion) {
|
||||
self.suggestions.push(suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggestions matching the pattern
|
||||
pub fn suggest(&self, partial: &str) -> Vec<String> {
|
||||
self.suggestions
|
||||
.iter()
|
||||
.filter(|s| s.starts_with(partial) || partial.is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get first matching suggestion
|
||||
pub fn complete(&self, partial: &str) -> Option<String> {
|
||||
self.suggestions
|
||||
.iter()
|
||||
.find(|s| s.starts_with(partial))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
// Smart completion based on pattern type
|
||||
match self.pattern {
|
||||
PatternType::Email if !partial.contains('@') && !partial.is_empty() => {
|
||||
Some("@example.com".to_string())
|
||||
}
|
||||
PatternType::Url if !partial.starts_with("http") && partial.is_empty() => {
|
||||
Some("https://".to_string())
|
||||
}
|
||||
PatternType::Port if !partial.contains(':') && !partial.is_empty() => {
|
||||
Some(":8080".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_history_completer() {
|
||||
let mut completer = HistoryCompleter::new(3);
|
||||
completer.add("apple".to_string());
|
||||
completer.add("application".to_string());
|
||||
|
||||
assert_eq!(completer.get_history().len(), 2);
|
||||
assert_eq!(completer.get_history()[0], "application");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_max_size() {
|
||||
let mut completer = HistoryCompleter::new(2);
|
||||
completer.add("first".to_string());
|
||||
completer.add("second".to_string());
|
||||
completer.add("third".to_string());
|
||||
|
||||
assert_eq!(completer.get_history().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_completer() {
|
||||
let options = vec!["apple".to_string(), "application".to_string(), "banana".to_string()];
|
||||
let completer = FilterCompleter::new(options);
|
||||
|
||||
let filtered = completer.filter("app");
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert!(filtered.iter().any(|s| s == "apple"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_completer_email() {
|
||||
let mut completer = PatternCompleter::new(PatternType::Email);
|
||||
completer.add_suggestion("@mydomain.com".to_string());
|
||||
|
||||
let suggestions = completer.suggest("@");
|
||||
assert!(!suggestions.is_empty());
|
||||
assert!(suggestions.iter().any(|s| s.contains("mydomain")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_completer_smart() {
|
||||
let completer = PatternCompleter::new(PatternType::Email);
|
||||
|
||||
// Smart completion adds @example.com when there's no @
|
||||
let completion = completer.complete("user");
|
||||
assert_eq!(completion, Some("@example.com".to_string()));
|
||||
}
|
||||
}
|
||||
201
crates/typedialog-core/src/backends/cli.rs
Normal file
201
crates/typedialog-core/src/backends/cli.rs
Normal file
@ -0,0 +1,201 @@
|
||||
//! CLI Backend using inquire for interactive prompts
|
||||
//!
|
||||
//! This backend provides the existing inquire-based CLI interface.
|
||||
//! It will be the primary implementation of FormBackend for terminal-based forms.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{FieldDefinition, DisplayItem, FieldType};
|
||||
use crate::prompts;
|
||||
use super::{FormBackend, RenderContext};
|
||||
|
||||
/// CLI Backend implementation using inquire
|
||||
pub struct InquireBackend;
|
||||
|
||||
impl InquireBackend {
|
||||
/// Create a new CLI backend instance
|
||||
pub fn new() -> Self {
|
||||
InquireBackend
|
||||
}
|
||||
|
||||
#[allow(clippy::only_used_in_recursion)]
|
||||
fn execute_field_sync(&self, field: &FieldDefinition) -> Result<Value> {
|
||||
let is_required = field.required.unwrap_or(false);
|
||||
let required_marker = if is_required { " *" } else { " (optional)" };
|
||||
|
||||
match field.field_type {
|
||||
FieldType::Text => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::text(&prompt_with_marker, field.default.as_deref(), field.placeholder.as_deref())?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return self.execute_field_sync(field);
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::Confirm => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let default_bool = field.default.as_deref().and_then(|s| match s.to_lowercase().as_str() {
|
||||
"true" | "yes" => Some(true),
|
||||
"false" | "no" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
let result = prompts::confirm(&prompt_with_marker, default_bool, None)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::Password => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let with_toggle = field.placeholder.as_deref() == Some("toggle");
|
||||
let result = prompts::password(&prompt_with_marker, with_toggle)?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return self.execute_field_sync(field);
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::Select => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field.options.clone().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("Select field requires 'options'")
|
||||
})?;
|
||||
let result = prompts::select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
field.page_size,
|
||||
field.vim_mode.unwrap_or(false),
|
||||
)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::MultiSelect => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let options = field.options.clone().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("MultiSelect field requires 'options'")
|
||||
})?;
|
||||
let results = prompts::multi_select(
|
||||
&prompt_with_marker,
|
||||
options,
|
||||
field.page_size,
|
||||
field.vim_mode.unwrap_or(false),
|
||||
)?;
|
||||
|
||||
if is_required && results.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please select at least one option.");
|
||||
return self.execute_field_sync(field);
|
||||
}
|
||||
Ok(serde_json::json!(results))
|
||||
}
|
||||
|
||||
FieldType::Editor => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let result = prompts::editor(&prompt_with_marker, field.file_extension.as_deref(), field.prefix_text.as_deref())?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return self.execute_field_sync(field);
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::Date => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let week_start = field.week_start.as_deref().unwrap_or("Mon");
|
||||
let result = prompts::date(
|
||||
&prompt_with_marker,
|
||||
field.default.as_deref(),
|
||||
field.min_date.as_deref(),
|
||||
field.max_date.as_deref(),
|
||||
week_start,
|
||||
)?;
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
FieldType::Custom => {
|
||||
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
|
||||
let type_name = field.custom_type.as_ref().ok_or_else(|| {
|
||||
crate::Error::form_parse_failed("Custom field requires 'custom_type'")
|
||||
})?;
|
||||
let result = prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
|
||||
|
||||
if is_required && result.is_empty() {
|
||||
eprintln!("⚠ This field is required. Please enter a value.");
|
||||
return self.execute_field_sync(field);
|
||||
}
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InquireBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FormBackend for InquireBackend {
|
||||
async fn initialize(&mut self) -> Result<()> {
|
||||
// No initialization needed for CLI backend
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> {
|
||||
item.render(&context.results);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result<Value> {
|
||||
// Wrap synchronous field execution in async context
|
||||
self.execute_field_sync(field)
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<()> {
|
||||
// No cleanup needed for CLI backend
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_available() -> bool {
|
||||
// CLI backend is always available
|
||||
true
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"cli"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inquire_backend_new() {
|
||||
let backend = InquireBackend::new();
|
||||
assert_eq!(backend.name(), "cli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inquire_backend_available() {
|
||||
assert!(InquireBackend::is_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inquire_backend_default() {
|
||||
let backend = InquireBackend::default();
|
||||
assert_eq!(backend.name(), "cli");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inquire_backend_lifecycle() {
|
||||
let mut backend = InquireBackend::new();
|
||||
assert!(backend.initialize().await.is_ok());
|
||||
assert!(backend.shutdown().await.is_ok());
|
||||
}
|
||||
}
|
||||
184
crates/typedialog-core/src/backends/mod.rs
Normal file
184
crates/typedialog-core/src/backends/mod.rs
Normal file
@ -0,0 +1,184 @@
|
||||
//! Form rendering backends abstraction
|
||||
//!
|
||||
//! This module provides a trait-based abstraction for different form rendering
|
||||
//! backends (CLI with inquire, TUI with ratatui, Web with axum, etc.).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use crate::error::Result;
|
||||
use crate::form_parser::{FieldDefinition, DisplayItem};
|
||||
|
||||
/// Context passed to rendering operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderContext {
|
||||
/// Previous field results (for conditional rendering)
|
||||
pub results: HashMap<String, Value>,
|
||||
/// Optional locale override
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for form rendering backends
|
||||
///
|
||||
/// Implementations handle the rendering of display items and execution of form fields.
|
||||
/// Different backends can target different environments (CLI, TUI, Web, etc.).
|
||||
#[async_trait]
|
||||
pub trait FormBackend: Send + Sync {
|
||||
/// Initialize the backend (setup terminal, start server, etc.)
|
||||
async fn initialize(&mut self) -> Result<()>;
|
||||
|
||||
/// Render a display item (header, section, border, footer, etc.)
|
||||
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>;
|
||||
|
||||
/// Execute a field and return user input (field-by-field mode)
|
||||
async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value>;
|
||||
|
||||
/// Execute complete form with all fields at once (complete mode)
|
||||
/// Returns all field values as a map
|
||||
async fn execute_form_complete(
|
||||
&mut self,
|
||||
form: &crate::form_parser::FormDefinition,
|
||||
items: &[DisplayItem],
|
||||
fields: &[FieldDefinition],
|
||||
) -> Result<std::collections::HashMap<String, Value>> {
|
||||
// Default implementation: fall back to field-by-field mode
|
||||
let mut results = std::collections::HashMap::new();
|
||||
let mut context = RenderContext {
|
||||
results: results.clone(),
|
||||
locale: form.locale.clone(),
|
||||
};
|
||||
|
||||
for item in items {
|
||||
self.render_display_item(item, &context).await?;
|
||||
}
|
||||
|
||||
for field in fields {
|
||||
context.results = results.clone();
|
||||
let value = self.execute_field(field, &context).await?;
|
||||
results.insert(field.name.clone(), value);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Cleanup/shutdown the backend
|
||||
async fn shutdown(&mut self) -> Result<()>;
|
||||
|
||||
/// Check if this backend is available on the current system
|
||||
fn is_available() -> bool
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Get the backend name (for logging/debugging)
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Backend type enumeration for factory pattern
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum BackendType {
|
||||
/// CLI backend using inquire
|
||||
Cli,
|
||||
/// TUI backend using ratatui
|
||||
#[cfg(feature = "tui")]
|
||||
Tui,
|
||||
/// Web backend using axum
|
||||
#[cfg(feature = "web")]
|
||||
Web { port: u16 },
|
||||
}
|
||||
|
||||
/// Factory for creating backend instances
|
||||
pub struct BackendFactory;
|
||||
|
||||
impl BackendFactory {
|
||||
/// Create a backend instance based on the provided type
|
||||
pub fn create(backend_type: BackendType) -> Result<Box<dyn FormBackend>> {
|
||||
match backend_type {
|
||||
BackendType::Cli => {
|
||||
#[cfg(feature = "cli")]
|
||||
{
|
||||
Ok(Box::new(cli::InquireBackend::new()))
|
||||
}
|
||||
#[cfg(not(feature = "cli"))]
|
||||
{
|
||||
Err(crate::error::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
"CLI backend not enabled. Compile with --features cli",
|
||||
))
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "tui")]
|
||||
BackendType::Tui => {
|
||||
Ok(Box::new(tui::RatatuiBackend::new()))
|
||||
}
|
||||
#[cfg(feature = "web")]
|
||||
BackendType::Web { port } => {
|
||||
Ok(Box::new(web::WebBackend::new(port)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-detect the best available backend
|
||||
pub fn auto_detect() -> BackendType {
|
||||
// Check environment variable override
|
||||
if let Ok(backend) = std::env::var("TYPEDIALOG_BACKEND") {
|
||||
match backend.to_lowercase().as_str() {
|
||||
"tui" => {
|
||||
#[cfg(feature = "tui")]
|
||||
return BackendType::Tui;
|
||||
#[cfg(not(feature = "tui"))]
|
||||
{}
|
||||
}
|
||||
"web" => {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let port = std::env::var("TYPEDIALOG_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.unwrap_or(9000);
|
||||
return BackendType::Web { port };
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to CLI backend
|
||||
BackendType::Cli
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI Backend - using inquire for interactive prompts
|
||||
#[cfg(feature = "cli")]
|
||||
pub mod cli;
|
||||
|
||||
/// TUI Backend - using ratatui for terminal UI
|
||||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
/// Web Backend - using axum for web forms
|
||||
#[cfg(feature = "web")]
|
||||
pub mod web;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_backend_type_debug() {
|
||||
let backend = BackendType::Cli;
|
||||
let debug_str = format!("{:?}", backend);
|
||||
assert!(debug_str.contains("Cli"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_context_clone() {
|
||||
let ctx = RenderContext {
|
||||
results: HashMap::new(),
|
||||
locale: Some("en-US".to_string()),
|
||||
};
|
||||
let cloned = ctx.clone();
|
||||
assert_eq!(cloned.locale, ctx.locale);
|
||||
}
|
||||
}
|
||||
2254
crates/typedialog-core/src/backends/tui.rs
Normal file
2254
crates/typedialog-core/src/backends/tui.rs
Normal file
File diff suppressed because it is too large
Load Diff
1228
crates/typedialog-core/src/backends/web/mod.rs
Normal file
1228
crates/typedialog-core/src/backends/web/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
155
crates/typedialog-core/src/cli_common.rs
Normal file
155
crates/typedialog-core/src/cli_common.rs
Normal file
@ -0,0 +1,155 @@
|
||||
//! Common CLI patterns and help text for all typedialog binaries
|
||||
|
||||
/// Long help for typedialog (CLI) main command
|
||||
pub const CLI_MAIN_LONG_ABOUT: &str = r#"typedialog - Create interactive forms and prompts with multiple backends.
|
||||
|
||||
By default uses CLI backend (step-by-step prompts). Switch backends using TYPEDIALOG_BACKEND
|
||||
environment variable or use specialized binaries:
|
||||
- typedialog: CLI backend (this tool)
|
||||
- typedialog-tui: Terminal UI backend
|
||||
- typedialog-web: Web server backend
|
||||
|
||||
All backends produce identical JSON output from the same TOML form.
|
||||
|
||||
BACKENDS:
|
||||
CLI Step-by-step prompts (scriptable, default)
|
||||
Run: typedialog form <config>
|
||||
|
||||
TUI 3-panel terminal UI (best for interactive use)
|
||||
Run: typedialog-tui <config>
|
||||
Or: TYPEDIALOG_BACKEND=tui typedialog form <config>
|
||||
|
||||
Web Browser-based interface (best for teams)
|
||||
Run: typedialog-web <config> --port 8080
|
||||
Or: TYPEDIALOG_BACKEND=web typedialog form <config>
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
TYPEDIALOG_BACKEND Backend to use: 'cli', 'tui', or 'web' (default: 'cli')
|
||||
TYPEDIALOG_PORT Port for web backend (default: 9000)
|
||||
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
|
||||
Priority: CLI flag > form locale > TYPEDIALOG_LANG > LANG > LC_ALL > default
|
||||
LANG System locale (fallback if TYPEDIALOG_LANG not set)
|
||||
LC_ALL Alternate locale setting (fallback to LANG)
|
||||
|
||||
EXAMPLES:
|
||||
# Use TUI backend instead of CLI
|
||||
TYPEDIALOG_BACKEND=tui typedialog form config.toml
|
||||
|
||||
# Use Web backend on custom port
|
||||
TYPEDIALOG_BACKEND=web TYPEDIALOG_PORT=8080 typedialog form config.toml
|
||||
|
||||
# Force Spanish locale
|
||||
TYPEDIALOG_LANG=es-ES typedialog form config.toml"#;
|
||||
|
||||
/// Long help for typedialog-tui
|
||||
pub const TUI_MAIN_LONG_ABOUT: &str = r#"typedialog-tui - Terminal UI tool for interactive forms.
|
||||
|
||||
Provides an enhanced 3-panel terminal interface (left: field list, right: input editor, bottom: buttons)
|
||||
with conditional field visibility and smart defaults.
|
||||
|
||||
This is the TUI-specific binary. To use TUI backend with typedialog CLI, set:
|
||||
TYPEDIALOG_BACKEND=tui typedialog form <config>
|
||||
|
||||
Available backends via typedialog main command:
|
||||
- typedialog: CLI backend (step-by-step prompts)
|
||||
- typedialog-tui: TUI backend (this tool, recommended for interactive use)
|
||||
- typedialog-web: Web backend (browser-based)
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
|
||||
LANG System locale (fallback if TYPEDIALOG_LANG not set)
|
||||
LC_ALL Alternate locale setting (fallback to LANG)
|
||||
|
||||
EXAMPLES:
|
||||
# Run TUI form directly
|
||||
typedialog-tui config.toml
|
||||
|
||||
# With Spanish locale
|
||||
TYPEDIALOG_LANG=es-ES typedialog-tui config.toml
|
||||
|
||||
# Output as JSON
|
||||
typedialog-tui config.toml --format json > results.json"#;
|
||||
|
||||
/// Long help for typedialog-web
|
||||
pub const WEB_MAIN_LONG_ABOUT: &str = r#"typedialog-web - Web server for interactive forms.
|
||||
|
||||
Provides an HTTP server for filling out forms via web browser. Access forms at:
|
||||
http://localhost:PORT/form/CONFIG_NAME
|
||||
|
||||
This is the Web-specific binary. To use Web backend with typedialog CLI, set:
|
||||
TYPEDIALOG_BACKEND=web typedialog form <config>
|
||||
|
||||
Available backends via typedialog main command:
|
||||
- typedialog: CLI backend (step-by-step prompts)
|
||||
- typedialog-tui: TUI backend (terminal UI)
|
||||
- typedialog-web: Web backend (this tool, recommended for teams)
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
TYPEDIALOG_PORT Port to listen on (default: 9000, overridden by --port flag)
|
||||
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
|
||||
LANG System locale (fallback if TYPEDIALOG_LANG not set)
|
||||
LC_ALL Alternate locale setting (fallback to LANG)
|
||||
|
||||
EXAMPLES:
|
||||
# Run web server on default port 9000
|
||||
typedialog-web config.toml
|
||||
|
||||
# Run on custom port via environment variable
|
||||
TYPEDIALOG_PORT=8080 typedialog-web config.toml
|
||||
|
||||
# Run on custom port via flag (takes precedence)
|
||||
typedialog-web config.toml --port 3000
|
||||
|
||||
# With Spanish locale
|
||||
TYPEDIALOG_LANG=es-ES typedialog-web config.toml --port 8080"#;
|
||||
|
||||
/// Help text explaining environment variables
|
||||
pub const ENV_VARS_HELP: &str = r#"
|
||||
ENVIRONMENT VARIABLES:
|
||||
TYPEDIALOG_BACKEND Backend to use: 'cli', 'tui', or 'web' (default: 'cli')
|
||||
Only used by 'typedialog' command
|
||||
TYPEDIALOG_PORT Port for web backend (default: 9000)
|
||||
TYPEDIALOG_LANG Locale for form localization (e.g., 'en-US', 'es-ES')
|
||||
LANG System locale (fallback if TYPEDIALOG_LANG not set)
|
||||
LC_ALL Alternate locale setting (fallback to LANG)
|
||||
|
||||
EXAMPLES:
|
||||
# Use TUI backend instead of CLI
|
||||
TYPEDIALOG_BACKEND=tui typedialog form config.toml
|
||||
|
||||
# Use Web backend on custom port
|
||||
TYPEDIALOG_BACKEND=web TYPEDIALOG_PORT=8080 typedialog form config.toml
|
||||
|
||||
# Force Spanish locale
|
||||
TYPEDIALOG_LANG=es-ES typedialog form config.toml
|
||||
"#;
|
||||
|
||||
/// Help text explaining backends
|
||||
pub const BACKENDS_HELP: &str = r#"
|
||||
BACKENDS:
|
||||
CLI Step-by-step prompts (scriptable, default)
|
||||
Run: typedialog form <config>
|
||||
|
||||
TUI 3-panel terminal UI (best for interactive use)
|
||||
Run: typedialog-tui <config>
|
||||
Or: TYPEDIALOG_BACKEND=tui typedialog form <config>
|
||||
|
||||
Web Browser-based interface (best for teams)
|
||||
Run: typedialog-web <config> --port 8080
|
||||
Or: TYPEDIALOG_BACKEND=web typedialog form <config>
|
||||
|
||||
All backends produce identical JSON output from the same TOML form definition.
|
||||
"#;
|
||||
|
||||
/// Help text for --locale flag
|
||||
pub const LOCALE_FLAG_HELP: &str =
|
||||
"Locale override for form localization (e.g., 'en-US', 'es-ES')\n\
|
||||
Priority: CLI flag > form locale > TYPEDIALOG_LANG > LANG > LC_ALL > default (en-US)";
|
||||
|
||||
/// Help text for --format flag
|
||||
pub const FORMAT_FLAG_HELP: &str =
|
||||
"Output format: 'json', 'yaml', 'toml', or 'text' (default: text)\n\
|
||||
JSON format enables integration with other tools (jq, terraform, etc.)";
|
||||
|
||||
/// Help text for --out flag
|
||||
pub const OUT_FLAG_HELP: &str = "Output file (if not specified, writes to stdout)";
|
||||
46
crates/typedialog-core/src/config/loader.rs
Normal file
46
crates/typedialog-core/src/config/loader.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Configuration file loader
|
||||
|
||||
use crate::config::TypeDialogConfig;
|
||||
use crate::error::{Error, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Load global configuration from ~/.config/typedialog/config.toml
|
||||
///
|
||||
/// If the file doesn't exist, returns the default configuration.
|
||||
pub fn load_global_config() -> Result<TypeDialogConfig> {
|
||||
let config_path = get_config_path()?;
|
||||
|
||||
if config_path.exists() {
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
toml::from_str(&content).map_err(|e| {
|
||||
Error::config_not_found(format!(
|
||||
"Failed to parse config file at {:?}: {}",
|
||||
config_path, e
|
||||
))
|
||||
})
|
||||
} else {
|
||||
Ok(TypeDialogConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the config file path (~/.config/typedialog/config.toml)
|
||||
fn get_config_path() -> Result<PathBuf> {
|
||||
#[cfg(feature = "i18n")]
|
||||
{
|
||||
use dirs::config_dir;
|
||||
let config_dir = config_dir().ok_or_else(|| {
|
||||
Error::config_not_found("Unable to determine config directory")
|
||||
})?;
|
||||
Ok(config_dir.join("typedialog").join("config.toml"))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "i18n"))]
|
||||
{
|
||||
// Fallback without dirs dependency
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map_err(|_| Error::config_not_found("Unable to determine home directory"))
|
||||
.map(|home| PathBuf::from(home).join(".config/typedialog/config.toml"))
|
||||
}
|
||||
}
|
||||
68
crates/typedialog-core/src/config/mod.rs
Normal file
68
crates/typedialog-core/src/config/mod.rs
Normal file
@ -0,0 +1,68 @@
|
||||
//! Configuration management for typedialog
|
||||
//!
|
||||
//! Handles global configuration loading and defaults.
|
||||
|
||||
mod loader;
|
||||
|
||||
pub use loader::load_global_config;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Global configuration for typedialog
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeDialogConfig {
|
||||
/// Default locale (e.g., "en-US", "es-ES")
|
||||
pub locale: Option<String>,
|
||||
|
||||
/// Path to locales directory
|
||||
pub locales_path: PathBuf,
|
||||
|
||||
/// Path to templates directory
|
||||
pub templates_path: PathBuf,
|
||||
|
||||
/// Fallback locale when the requested locale is not available
|
||||
pub fallback_locale: String,
|
||||
}
|
||||
|
||||
impl Default for TypeDialogConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
locale: None,
|
||||
locales_path: PathBuf::from("./locales"),
|
||||
templates_path: PathBuf::from("./templates"),
|
||||
fallback_locale: "en-US".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypeDialogConfig {
|
||||
/// Create a new config with default values
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the default locale
|
||||
pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
|
||||
self.locale = Some(locale.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the locales directory path
|
||||
pub fn with_locales_path(mut self, path: PathBuf) -> Self {
|
||||
self.locales_path = path;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the templates directory path
|
||||
pub fn with_templates_path(mut self, path: PathBuf) -> Self {
|
||||
self.templates_path = path;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the fallback locale
|
||||
pub fn with_fallback_locale(mut self, locale: impl Into<String>) -> Self {
|
||||
self.fallback_locale = locale.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
182
crates/typedialog-core/src/error.rs
Normal file
182
crates/typedialog-core/src/error.rs
Normal file
@ -0,0 +1,182 @@
|
||||
//! Error handling for typedialog
|
||||
//!
|
||||
//! Provides structured error types for all operations.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
/// Errors that can occur during form operations
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
kind: ErrorKind,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Error kinds for form operations
|
||||
#[derive(Debug)]
|
||||
pub enum ErrorKind {
|
||||
/// User cancelled the prompt
|
||||
Cancelled,
|
||||
/// Form parsing failed
|
||||
FormParseFailed,
|
||||
/// I/O error
|
||||
Io,
|
||||
/// TOML parsing error
|
||||
TomlParse,
|
||||
/// Validation failed
|
||||
ValidationFailed,
|
||||
/// i18n (internationalization) error
|
||||
I18nFailed,
|
||||
/// Template error
|
||||
TemplateFailed,
|
||||
/// Configuration error
|
||||
ConfigNotFound,
|
||||
/// Other errors
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Create a new error with a specific kind and message
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a cancelled error
|
||||
pub fn cancelled() -> Self {
|
||||
Self::new(ErrorKind::Cancelled, "Operation cancelled")
|
||||
}
|
||||
|
||||
/// Create a form parse error
|
||||
pub fn form_parse_failed(msg: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::FormParseFailed, msg)
|
||||
}
|
||||
|
||||
/// Create an I/O error
|
||||
pub fn io(source: io::Error) -> Self {
|
||||
Self::new(ErrorKind::Io, format!("I/O error: {}", source))
|
||||
}
|
||||
|
||||
/// Create a TOML parse error
|
||||
pub fn toml_parse(source: toml::de::Error) -> Self {
|
||||
Self::new(ErrorKind::TomlParse, format!("TOML parse error: {}", source))
|
||||
}
|
||||
|
||||
/// Create a validation error
|
||||
pub fn validation_failed(msg: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::ValidationFailed, msg)
|
||||
}
|
||||
|
||||
/// Create an i18n error
|
||||
pub fn i18n_failed(msg: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::I18nFailed, msg)
|
||||
}
|
||||
|
||||
/// Create a template error
|
||||
pub fn template_failed(msg: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::TemplateFailed, msg)
|
||||
}
|
||||
|
||||
/// Create a config error
|
||||
pub fn config_not_found(msg: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::ConfigNotFound, msg)
|
||||
}
|
||||
|
||||
/// Get the error kind
|
||||
pub fn kind(&self) -> &ErrorKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// Get the error message
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// Check if this is a cancellation error
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
matches!(self.kind, ErrorKind::Cancelled)
|
||||
}
|
||||
|
||||
/// Check if this is a parse error
|
||||
pub fn is_parse_error(&self) -> bool {
|
||||
matches!(
|
||||
self.kind,
|
||||
ErrorKind::FormParseFailed | ErrorKind::TomlParse
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::de::Error> for Error {
|
||||
fn from(err: toml::de::Error) -> Self {
|
||||
Self::toml_parse(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::new(ErrorKind::Other, format!("JSON error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_yaml::Error> for Error {
|
||||
fn from(err: serde_yaml::Error) -> Self {
|
||||
Self::new(ErrorKind::Other, format!("YAML error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::ParseError> for Error {
|
||||
fn from(err: chrono::ParseError) -> Self {
|
||||
Self::new(ErrorKind::ValidationFailed, format!("Date parsing error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<inquire::InquireError> for Error {
|
||||
fn from(err: inquire::InquireError) -> Self {
|
||||
match err {
|
||||
inquire::InquireError::OperationCanceled => Self::cancelled(),
|
||||
_ => Self::new(ErrorKind::Other, format!("Prompt error: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type for typedialog operations
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = Error::validation_failed("test error");
|
||||
assert_eq!(err.to_string(), "test error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_kind() {
|
||||
let err = Error::cancelled();
|
||||
assert!(err.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_error() {
|
||||
let err = Error::form_parse_failed("parse failed");
|
||||
assert!(err.is_parse_error());
|
||||
}
|
||||
}
|
||||
1292
crates/typedialog-core/src/form_parser.rs
Normal file
1292
crates/typedialog-core/src/form_parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
128
crates/typedialog-core/src/helpers.rs
Normal file
128
crates/typedialog-core/src/helpers.rs
Normal file
@ -0,0 +1,128 @@
|
||||
//! Helper utilities for value conversions
|
||||
//!
|
||||
//! Provides conversion functions between JSON values and other formats
|
||||
//! for serialization and display purposes.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Convert a HashMap of JSON values to a formatted string
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `results` - HashMap of field names to JSON values
|
||||
/// * `format` - Output format: "json", "yaml", or "text"
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Formatted string representation of the results
|
||||
pub fn format_results(
|
||||
results: &HashMap<String, Value>,
|
||||
format: &str,
|
||||
) -> crate::error::Result<String> {
|
||||
match format {
|
||||
"json" => {
|
||||
let json_obj = serde_json::to_value(results)
|
||||
.map_err(|e| crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("JSON serialization error: {}", e),
|
||||
))?;
|
||||
Ok(serde_json::to_string_pretty(&json_obj)?)
|
||||
}
|
||||
"yaml" => {
|
||||
let yaml_string = serde_yaml::to_string(results)
|
||||
.map_err(|e| crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("YAML serialization error: {}", e),
|
||||
))?;
|
||||
Ok(yaml_string)
|
||||
}
|
||||
"text" => {
|
||||
let mut output = String::new();
|
||||
for (key, value) in results {
|
||||
output.push_str(&format!("{}: {}\n", key, format_value(value)));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
_ => Err(crate::Error::new(
|
||||
crate::error::ErrorKind::ValidationFailed,
|
||||
format!("Unknown output format: {}", format),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a JSON value for display
|
||||
fn format_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(arr) => {
|
||||
let items: Vec<String> = arr.iter().map(format_value).collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
let items: Vec<String> = obj
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}: {}", k, format_value(v)))
|
||||
.collect();
|
||||
format!("{{{}}}", items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert results to JSON value
|
||||
pub fn to_json_value(results: &HashMap<String, Value>) -> Value {
|
||||
json!(results)
|
||||
}
|
||||
|
||||
/// Convert results to JSON string
|
||||
pub fn to_json_string(results: &HashMap<String, Value>) -> crate::error::Result<String> {
|
||||
serde_json::to_string(&to_json_value(results))
|
||||
.map_err(|e| crate::Error::new(
|
||||
crate::error::ErrorKind::Other,
|
||||
format!("JSON error: {}", e),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_results_json() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("name".to_string(), json!("Alice"));
|
||||
results.insert("age".to_string(), json!(30));
|
||||
|
||||
let formatted = format_results(&results, "json").unwrap();
|
||||
assert!(formatted.contains("name"));
|
||||
assert!(formatted.contains("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_results_text() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("name".to_string(), json!("Bob"));
|
||||
results.insert("role".to_string(), json!("Admin"));
|
||||
|
||||
let formatted = format_results(&results, "text").unwrap();
|
||||
assert!(formatted.contains("name: Bob"));
|
||||
assert!(formatted.contains("role: Admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_array() {
|
||||
let arr = json!(["a", "b", "c"]);
|
||||
assert_eq!(format_value(&arr), "[a, b, c]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_object() {
|
||||
let obj = json!({"x": 1, "y": 2});
|
||||
let formatted = format_value(&obj);
|
||||
assert!(formatted.contains("x: 1"));
|
||||
assert!(formatted.contains("y: 2"));
|
||||
}
|
||||
}
|
||||
158
crates/typedialog-core/src/i18n/loader.rs
Normal file
158
crates/typedialog-core/src/i18n/loader.rs
Normal file
@ -0,0 +1,158 @@
|
||||
//! Locale file loader for Fluent and TOML translations
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
/// Loader for locale files (both .ftl and .toml)
|
||||
pub struct LocaleLoader {
|
||||
locales_path: PathBuf,
|
||||
}
|
||||
|
||||
impl LocaleLoader {
|
||||
/// Create a new locale loader with the given locales directory path
|
||||
pub fn new(locales_path: PathBuf) -> Self {
|
||||
Self { locales_path }
|
||||
}
|
||||
|
||||
/// Load Fluent (.ftl) resources for a specific locale
|
||||
///
|
||||
/// Searches for files matching `locales/{locale}/*.ftl`
|
||||
pub fn load_fluent(&self, locale: &LanguageIdentifier) -> Result<Vec<String>> {
|
||||
let locale_dir = self.locales_path.join(locale.to_string());
|
||||
|
||||
if !locale_dir.exists() {
|
||||
return Err(Error::i18n_failed(format!(
|
||||
"Locale directory not found: {:?}",
|
||||
locale_dir
|
||||
)));
|
||||
}
|
||||
|
||||
let mut resources = Vec::new();
|
||||
|
||||
match fs::read_dir(&locale_dir) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("ftl") {
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => resources.push(content),
|
||||
Err(e) => {
|
||||
return Err(Error::i18n_failed(format!(
|
||||
"Failed to read {:?}: {}",
|
||||
path, e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Error::i18n_failed(format!(
|
||||
"Failed to read locale directory {:?}: {}",
|
||||
locale_dir, e
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// Load TOML-based translations for a specific locale
|
||||
///
|
||||
/// Searches for file `locales/{locale}.toml` and flattens nested structure
|
||||
pub fn load_toml(&self, locale: &LanguageIdentifier) -> Result<HashMap<String, String>> {
|
||||
let toml_file = self.locales_path.join(format!("{}.toml", locale));
|
||||
|
||||
if !toml_file.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
match fs::read_to_string(&toml_file) {
|
||||
Ok(content) => {
|
||||
match toml::from_str::<toml::map::Map<String, toml::Value>>(&content) {
|
||||
Ok(root) => Ok(Self::flatten_toml(root, "")),
|
||||
Err(e) => Err(Error::i18n_failed(format!(
|
||||
"Failed to parse TOML file {:?}: {}",
|
||||
toml_file, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(Error::i18n_failed(format!(
|
||||
"Failed to read TOML file {:?}: {}",
|
||||
toml_file, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten nested TOML structure into dot-notation keys
|
||||
///
|
||||
/// Example: `[forms.registration] username = "Name"` → `"forms.registration.username": "Name"`
|
||||
fn flatten_toml(map: toml::map::Map<String, toml::Value>, prefix: &str) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
for (key, value) in map {
|
||||
let full_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, key)
|
||||
};
|
||||
|
||||
match value {
|
||||
toml::Value::String(s) => {
|
||||
result.insert(full_key, s);
|
||||
}
|
||||
toml::Value::Table(table) => {
|
||||
let nested = Self::flatten_toml(table, &full_key);
|
||||
result.extend(nested);
|
||||
}
|
||||
toml::Value::Array(arr) => {
|
||||
// For arrays, convert to JSON representation
|
||||
if let Ok(json) = serde_json::to_string(&arr) {
|
||||
result.insert(full_key, json);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// For other types, convert to string
|
||||
result.insert(full_key, value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_flatten_toml() {
|
||||
let mut map = toml::map::Map::new();
|
||||
map.insert(
|
||||
"forms".to_string(),
|
||||
toml::Value::Table({
|
||||
let mut t = toml::map::Map::new();
|
||||
t.insert(
|
||||
"registration".to_string(),
|
||||
toml::Value::Table({
|
||||
let mut t2 = toml::map::Map::new();
|
||||
t2.insert("title".to_string(), toml::Value::String("Registration".to_string()));
|
||||
t2
|
||||
}),
|
||||
);
|
||||
t
|
||||
}),
|
||||
);
|
||||
|
||||
let flattened = LocaleLoader::flatten_toml(map, "");
|
||||
assert_eq!(
|
||||
flattened.get("forms.registration.title"),
|
||||
Some(&"Registration".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
130
crates/typedialog-core/src/i18n/mod.rs
Normal file
130
crates/typedialog-core/src/i18n/mod.rs
Normal file
@ -0,0 +1,130 @@
|
||||
//! Internationalization (i18n) support using Fluent and TOML
|
||||
//!
|
||||
//! Provides multi-language support for forms, prompts, and messages.
|
||||
|
||||
mod loader;
|
||||
mod resolver;
|
||||
|
||||
pub use loader::LocaleLoader;
|
||||
pub use resolver::LocaleResolver;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use fluent::FluentArgs;
|
||||
use fluent_bundle::{FluentBundle, FluentResource};
|
||||
use std::collections::HashMap;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
/// I18n bundle that combines Fluent translations and TOML translations
|
||||
pub struct I18nBundle {
|
||||
bundle: FluentBundle<FluentResource>,
|
||||
fallback_bundle: FluentBundle<FluentResource>,
|
||||
toml_translations: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl I18nBundle {
|
||||
/// Create a new I18nBundle from a locale and fallback locale
|
||||
pub fn new(
|
||||
locale: LanguageIdentifier,
|
||||
fallback_locale: LanguageIdentifier,
|
||||
loader: &LocaleLoader,
|
||||
) -> Result<Self> {
|
||||
let bundle = Self::create_bundle(&locale, loader)?;
|
||||
let fallback_bundle = Self::create_bundle(&fallback_locale, loader)?;
|
||||
let toml_translations = loader.load_toml(&locale)?;
|
||||
|
||||
Ok(Self {
|
||||
bundle,
|
||||
fallback_bundle,
|
||||
toml_translations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a FluentBundle for a given locale
|
||||
fn create_bundle(
|
||||
locale: &LanguageIdentifier,
|
||||
loader: &LocaleLoader,
|
||||
) -> Result<FluentBundle<FluentResource>> {
|
||||
let mut bundle = FluentBundle::new(vec![locale.clone()]);
|
||||
|
||||
for resource_str in loader.load_fluent(locale)? {
|
||||
let resource = FluentResource::try_new(resource_str).map_err(|e| {
|
||||
Error::i18n_failed(format!("Fluent parse error: {:?}", e))
|
||||
})?;
|
||||
bundle.add_resource(resource).map_err(|e| {
|
||||
Error::i18n_failed(format!("Bundle add error: {:?}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
/// Translate a message key
|
||||
///
|
||||
/// Searches in order: main bundle → TOML translations → fallback bundle → missing key marker
|
||||
pub fn translate(&self, key: &str, args: Option<&FluentArgs>) -> String {
|
||||
// Try main bundle
|
||||
if let Some(msg) = self.bundle.get_message(key) {
|
||||
if let Some(pattern) = msg.value() {
|
||||
let mut errors = vec![];
|
||||
return self.bundle.format_pattern(pattern, args, &mut errors).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Try TOML translations
|
||||
if let Some(translation) = self.toml_translations.get(key) {
|
||||
return translation.clone();
|
||||
}
|
||||
|
||||
// Try fallback bundle
|
||||
if let Some(msg) = self.fallback_bundle.get_message(key) {
|
||||
if let Some(pattern) = msg.value() {
|
||||
let mut errors = vec![];
|
||||
return self.fallback_bundle.format_pattern(pattern, args, &mut errors).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Return missing marker for debugging
|
||||
format!("[MISSING: {}]", key)
|
||||
}
|
||||
|
||||
/// Check if a string looks like an i18n key
|
||||
///
|
||||
/// Heuristic: contains dots or starts with lowercase letter
|
||||
pub fn is_i18n_key(text: &str) -> bool {
|
||||
text.contains('.') || text.chars().next().is_some_and(|c| c.is_lowercase())
|
||||
}
|
||||
|
||||
/// Translate if the text looks like a key, otherwise return as-is
|
||||
pub fn translate_if_key(&self, text: &str, args: Option<&FluentArgs>) -> String {
|
||||
if Self::is_i18n_key(text) {
|
||||
self.translate(text, args)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_i18n_key() {
|
||||
assert!(I18nBundle::is_i18n_key("forms.title"));
|
||||
assert!(I18nBundle::is_i18n_key("registration"));
|
||||
assert!(!I18nBundle::is_i18n_key("Capitalized Text"));
|
||||
assert!(!I18nBundle::is_i18n_key("Mixed Case String"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_translation() {
|
||||
let loader = LocaleLoader::new(std::path::PathBuf::from("locales"));
|
||||
// This will fail if locales dir doesn't exist, but that's OK for now
|
||||
if let Ok(bundle) =
|
||||
I18nBundle::new("en-US".parse().unwrap(), "en-US".parse().unwrap(), &loader)
|
||||
{
|
||||
let result = bundle.translate("nonexistent.key", None);
|
||||
assert!(result.contains("MISSING"));
|
||||
}
|
||||
}
|
||||
}
|
||||
134
crates/typedialog-core/src/i18n/resolver.rs
Normal file
134
crates/typedialog-core/src/i18n/resolver.rs
Normal file
@ -0,0 +1,134 @@
|
||||
//! Locale resolution with priority-based fallback
|
||||
|
||||
use crate::config::TypeDialogConfig;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
/// Resolver for determining which locale to use based on multiple sources
|
||||
pub struct LocaleResolver {
|
||||
config: TypeDialogConfig,
|
||||
}
|
||||
|
||||
impl LocaleResolver {
|
||||
/// Create a new locale resolver with the given configuration
|
||||
pub fn new(config: TypeDialogConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Resolve the locale to use based on priority
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. CLI flag `--locale`
|
||||
/// 2. Form TOML attribute `locale`
|
||||
/// 3. Global config `~/.config/typedialog/config.toml`
|
||||
/// 4. Environment variable `TYPEDIALOG_LANG`
|
||||
/// 5. Environment variables `LANG` or `LC_ALL`
|
||||
/// 6. Fallback to "en-US"
|
||||
pub fn resolve(
|
||||
&self,
|
||||
cli_locale: Option<&str>,
|
||||
form_locale: Option<&str>,
|
||||
) -> LanguageIdentifier {
|
||||
// Priority 1: CLI flag
|
||||
if let Some(locale) = cli_locale {
|
||||
if let Ok(lang_id) = locale.parse() {
|
||||
return lang_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Form TOML attribute
|
||||
if let Some(locale) = form_locale {
|
||||
if let Ok(lang_id) = locale.parse() {
|
||||
return lang_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Global config
|
||||
if let Some(locale) = &self.config.locale {
|
||||
if let Ok(lang_id) = locale.parse() {
|
||||
return lang_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Environment variables
|
||||
if let Some(lang_id) = self.detect_system_locale() {
|
||||
return lang_id;
|
||||
}
|
||||
|
||||
// Priority 5: Fallback
|
||||
self.config
|
||||
.fallback_locale
|
||||
.parse()
|
||||
.unwrap_or_else(|_| "en-US".parse().unwrap())
|
||||
}
|
||||
|
||||
/// Detect system locale from environment variables
|
||||
/// Priority: TYPEDIALOG_LANG > LANG > LC_ALL
|
||||
fn detect_system_locale(&self) -> Option<LanguageIdentifier> {
|
||||
// Priority 1: TYPEDIALOG_LANG (explicit typedialog-specific setting)
|
||||
if let Ok(locale_str) = std::env::var("TYPEDIALOG_LANG") {
|
||||
if let Ok(lang_id) = locale_str.parse() {
|
||||
return Some(lang_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Try LANG, then LC_ALL (system locale)
|
||||
let locale_str = std::env::var("LANG")
|
||||
.or_else(|_| std::env::var("LC_ALL"))
|
||||
.ok()?;
|
||||
|
||||
// Extract just the language part (e.g., "en_US.UTF-8" → "en-US")
|
||||
let parts: Vec<&str> = locale_str.split_whitespace().collect();
|
||||
let base = parts.first().unwrap_or(&"en-US");
|
||||
|
||||
// Remove encoding (e.g., ".UTF-8")
|
||||
let lang_part = base.split('.').next().unwrap_or(base);
|
||||
|
||||
// Convert underscore to hyphen (e.g., "en_US" → "en-US")
|
||||
let normalized = lang_part.replace('_', "-");
|
||||
|
||||
normalized.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::TypeDialogConfig;
|
||||
|
||||
#[test]
|
||||
fn test_locale_resolution_priority() {
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config);
|
||||
|
||||
// CLI flag should have highest priority
|
||||
let locale = resolver.resolve(Some("es-ES"), Some("fr-FR"));
|
||||
assert_eq!(locale.to_string(), "es-ES");
|
||||
|
||||
// Form locale if no CLI flag
|
||||
let locale = resolver.resolve(None, Some("fr-FR"));
|
||||
assert_eq!(locale.to_string(), "fr-FR");
|
||||
|
||||
// Fallback if no CLI or form locale
|
||||
let locale = resolver.resolve(None, None);
|
||||
assert_eq!(locale.to_string(), "en-US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_locale_format() {
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config);
|
||||
|
||||
// Invalid format should fall through to next priority
|
||||
let locale = resolver.resolve(Some("invalid!!!"), Some("fr-FR"));
|
||||
assert_eq!(locale.to_string(), "fr-FR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_locale_parsing() {
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config);
|
||||
|
||||
// The actual system locale will vary, so just test parsing doesn't panic
|
||||
let _locale = resolver.detect_system_locale();
|
||||
}
|
||||
}
|
||||
116
crates/typedialog-core/src/lib.rs
Normal file
116
crates/typedialog-core/src/lib.rs
Normal file
@ -0,0 +1,116 @@
|
||||
//! typedialog - Interactive forms and prompts library
|
||||
//!
|
||||
//! A powerful library and CLI tool for creating interactive forms and prompts
|
||||
//! with support for multiple rendering backends (CLI, TUI, Web).
|
||||
//! Works with piped input for batch processing and scripts.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - 9 interactive prompt types (text, confirm, select, multi-select, password, custom, editor, date, form)
|
||||
//! - TOML-based form definitions for declarative UI
|
||||
//! - Multiple rendering backends: CLI (inquire), TUI (ratatui), Web (axum/HTMX)
|
||||
//! - Stdin-based fallback for non-interactive contexts
|
||||
//! - JSON/YAML output formats
|
||||
//! - Nickel schema integration for configuration management
|
||||
//! - Both library and CLI tool usage
|
||||
//!
|
||||
//! # Quick Start as Library
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use typedialog_core::prompts;
|
||||
//!
|
||||
//! // Simple text prompt
|
||||
//! let name = prompts::text("Enter your name", None, None)?;
|
||||
//! println!("Hello, {}!", name);
|
||||
//!
|
||||
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
//! ```
|
||||
//!
|
||||
//! # Quick Start with Backends
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
//! use typedialog_core::form_parser;
|
||||
//!
|
||||
//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let mut backend = BackendFactory::create(BackendType::Cli)?;
|
||||
//! let form = form_parser::parse_toml("[[fields]]\nname = \"username\"\ntype = \"text\"\n")?;
|
||||
//! let results = form_parser::execute_with_backend(form, &mut backend).await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Quick Start as CLI
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Text prompt with piped input
|
||||
//! echo "Alice" | typedialog text "Enter name"
|
||||
//!
|
||||
//! # Form execution
|
||||
//! cat input.txt | typedialog form myform.toml
|
||||
//!
|
||||
//! # Selection
|
||||
//! echo "Admin" | typedialog select "Choose role" Admin User Guest
|
||||
//!
|
||||
//! # Nickel integration
|
||||
//! typedialog nickel-to-form schema.ncl -o form.toml --flatten
|
||||
//! typedialog form-to-nickel form.toml results.json -o output.ncl --validate
|
||||
//! ```
|
||||
|
||||
pub mod error;
|
||||
pub mod form_parser;
|
||||
pub mod helpers;
|
||||
pub mod prompts;
|
||||
pub mod autocompletion;
|
||||
pub mod nickel;
|
||||
pub mod backends;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub mod config;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub mod i18n;
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
pub mod templates;
|
||||
|
||||
/// Common CLI patterns and help text
|
||||
pub mod cli_common;
|
||||
|
||||
// Re-export main types for convenient access
|
||||
pub use error::{Error, Result};
|
||||
pub use form_parser::{FieldDefinition, FieldType, FormDefinition, DisplayItem};
|
||||
pub use helpers::{format_results, to_json_value, to_json_string};
|
||||
pub use autocompletion::{HistoryCompleter, FilterCompleter, PatternCompleter};
|
||||
pub use backends::{FormBackend, BackendType, BackendFactory, RenderContext};
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub use config::TypeDialogConfig;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
pub use i18n::{I18nBundle, LocaleResolver};
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
pub use templates::{TemplateEngine, TemplateContextBuilder};
|
||||
|
||||
/// Library version
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert!(!VERSION.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_factory_cli() {
|
||||
let result = BackendFactory::create(BackendType::Cli);
|
||||
#[cfg(feature = "cli")]
|
||||
assert!(result.is_ok());
|
||||
#[cfg(not(feature = "cli"))]
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
239
crates/typedialog-core/src/nickel/cli.rs
Normal file
239
crates/typedialog-core/src/nickel/cli.rs
Normal file
@ -0,0 +1,239 @@
|
||||
//! Nickel CLI wrapper using std::process::Command
|
||||
//!
|
||||
//! Provides safe wrappers around nickel CLI commands:
|
||||
//! - `nickel query` - Extract metadata from .ncl files
|
||||
//! - `nickel export` - Export evaluated Nickel to JSON
|
||||
//! - `nickel typecheck` - Validate Nickel syntax and types
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
|
||||
/// Nickel CLI command wrapper
|
||||
pub struct NickelCli;
|
||||
|
||||
impl NickelCli {
|
||||
/// Verify that nickel CLI is installed and accessible
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `nickel` command is not found or cannot be executed.
|
||||
pub fn verify() -> Result<String> {
|
||||
let output = Command::new("nickel")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to execute 'nickel --version'. \
|
||||
Is nickel installed? Install from: https://nickel-lang.org/install\n\
|
||||
Error: {}",
|
||||
e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
"nickel command failed. Is nickel installed correctly?",
|
||||
));
|
||||
}
|
||||
|
||||
String::from_utf8(output.stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Invalid UTF-8 in nickel version output: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute `nickel query --format json` to extract metadata
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the .ncl file
|
||||
/// * `field` - Optional field path to query (e.g., "inputs", "inputs.user")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// JSON value containing the queried metadata
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the nickel command fails or output is invalid JSON.
|
||||
pub fn query(path: &Path, field: Option<&str>) -> Result<serde_json::Value> {
|
||||
let mut cmd = Command::new("nickel");
|
||||
cmd.arg("query")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg(path);
|
||||
|
||||
if let Some(f) = field {
|
||||
cmd.arg("--field").arg(f);
|
||||
}
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to execute 'nickel query' on {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"nickel query failed for {}: {}",
|
||||
path.display(),
|
||||
stderr
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Invalid UTF-8 in nickel query output: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to parse nickel query output as JSON: {}",
|
||||
e
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute `nickel export --format json` to export evaluated Nickel
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the .ncl file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// JSON value containing the exported Nickel configuration
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the nickel command fails or output is invalid JSON.
|
||||
pub fn export(path: &Path) -> Result<serde_json::Value> {
|
||||
let output = Command::new("nickel")
|
||||
.arg("export")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to execute 'nickel export' on {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"nickel export failed for {}: {}",
|
||||
path.display(),
|
||||
stderr
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!("Invalid UTF-8 in nickel export output: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to parse nickel export output as JSON: {}",
|
||||
e
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute `nickel typecheck` to validate Nickel file
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the .ncl file to typecheck
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Ok if typecheck succeeds, Err with detailed message if it fails
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the nickel typecheck command fails.
|
||||
pub fn typecheck(path: &Path) -> Result<()> {
|
||||
let output = Command::new("nickel")
|
||||
.arg("typecheck")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed to execute 'nickel typecheck' on {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"nickel typecheck failed for {}:\n{}",
|
||||
path.display(),
|
||||
stderr
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_verify_nickel_installed() {
|
||||
// This will fail if nickel is not installed, which is expected
|
||||
let result = NickelCli::verify();
|
||||
if result.is_ok() {
|
||||
println!("Nickel is installed: {:?}", result);
|
||||
} else {
|
||||
eprintln!("Nickel not found: {:?}", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
323
crates/typedialog-core/src/nickel/contracts.rs
Normal file
323
crates/typedialog-core/src/nickel/contracts.rs
Normal file
@ -0,0 +1,323 @@
|
||||
//! Contract Validator
|
||||
//!
|
||||
//! Validates Nickel contracts and predicates against JSON values.
|
||||
//!
|
||||
//! Supports common Nickel validation predicates:
|
||||
//! - `std.string.NonEmpty` - Non-empty string
|
||||
//! - `std.string.length.min N` - Minimum string length
|
||||
//! - `std.string.length.max N` - Maximum string length
|
||||
//! - `std.number.between A B` - Number in range [A, B]
|
||||
//! - `std.number.greater_than N` - Number > N
|
||||
//! - `std.number.less_than N` - Number < N
|
||||
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Validator for Nickel contracts and predicates
|
||||
pub struct ContractValidator;
|
||||
|
||||
impl ContractValidator {
|
||||
/// Validate a value against a Nickel contract
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The JSON value to validate
|
||||
/// * `contract` - The Nickel contract string (e.g., "String | std.string.NonEmpty")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Ok if validation succeeds, Err with descriptive message if it fails
|
||||
pub fn validate(value: &Value, contract: &str) -> Result<()> {
|
||||
// Extract the predicate from the contract (after the pipe)
|
||||
let predicate = contract
|
||||
.rfind('|')
|
||||
.map(|i| contract[i + 1..].trim())
|
||||
.unwrap_or(contract);
|
||||
|
||||
// Match common predicates
|
||||
if predicate.contains("std.string.NonEmpty") {
|
||||
return Self::validate_non_empty_string(value);
|
||||
}
|
||||
|
||||
if predicate.contains("std.string.length.min") {
|
||||
if let Some(n) = Self::extract_number(predicate, "std.string.length.min") {
|
||||
return Self::validate_min_length(value, n);
|
||||
}
|
||||
}
|
||||
|
||||
if predicate.contains("std.string.length.max") {
|
||||
if let Some(n) = Self::extract_number(predicate, "std.string.length.max") {
|
||||
return Self::validate_max_length(value, n);
|
||||
}
|
||||
}
|
||||
|
||||
if predicate.contains("std.number.between") {
|
||||
if let Some((a, b)) = Self::extract_range(predicate) {
|
||||
return Self::validate_between(value, a, b);
|
||||
}
|
||||
}
|
||||
|
||||
if predicate.contains("std.number.greater_than") {
|
||||
if let Some(n) = Self::extract_number(predicate, "std.number.greater_than") {
|
||||
return Self::validate_greater_than(value, n as f64);
|
||||
}
|
||||
}
|
||||
|
||||
if predicate.contains("std.number.less_than") {
|
||||
if let Some(n) = Self::extract_number(predicate, "std.number.less_than") {
|
||||
return Self::validate_less_than(value, n as f64);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown predicate - pass validation
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that string is non-empty
|
||||
fn validate_non_empty_string(value: &Value) -> Result<()> {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if s.is_empty() {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"String must not be empty (std.string.NonEmpty)".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected string value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate minimum string length
|
||||
fn validate_min_length(value: &Value, min: usize) -> Result<()> {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if s.len() < min {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("String must be at least {} characters (std.string.length.min {})", min, min),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected string value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate maximum string length
|
||||
fn validate_max_length(value: &Value, max: usize) -> Result<()> {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if s.len() > max {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("String must be at most {} characters (std.string.length.max {})", max, max),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected string value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate number is in range [a, b]
|
||||
fn validate_between(value: &Value, a: f64, b: f64) -> Result<()> {
|
||||
match value {
|
||||
Value::Number(n) => {
|
||||
if let Some(num) = n.as_f64() {
|
||||
if num >= a && num <= b {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!(
|
||||
"Number must be between {} and {} (std.number.between {} {})",
|
||||
a, b, a, b
|
||||
),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Invalid number value".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected number value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate number is greater than n
|
||||
fn validate_greater_than(value: &Value, n: f64) -> Result<()> {
|
||||
match value {
|
||||
Value::Number(num) => {
|
||||
if let Some(val) = num.as_f64() {
|
||||
if val > n {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Number must be greater than {} (std.number.greater_than {})", n, n),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Invalid number value".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected number value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate number is less than n
|
||||
fn validate_less_than(value: &Value, n: f64) -> Result<()> {
|
||||
match value {
|
||||
Value::Number(num) => {
|
||||
if let Some(val) = num.as_f64() {
|
||||
if val < n {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Number must be less than {} (std.number.less_than {})", n, n),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Invalid number value".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected number value".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a single number from predicate string
|
||||
fn extract_number(predicate: &str, pattern: &str) -> Option<usize> {
|
||||
let start = predicate.find(pattern)? + pattern.len();
|
||||
let rest = &predicate[start..];
|
||||
|
||||
// Extract digits after the pattern
|
||||
rest.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.trim_matches(|c: char| !c.is_ascii_digit()).parse().ok())
|
||||
}
|
||||
|
||||
/// Extract a range (a, b) from between predicate
|
||||
fn extract_range(predicate: &str) -> Option<(f64, f64)> {
|
||||
// Parse patterns like "std.number.between 0 100" or "std.number.between 0.5 99.9"
|
||||
let start = predicate.find("std.number.between")? + "std.number.between".len();
|
||||
let rest = predicate[start..].trim();
|
||||
|
||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let a = parts[0].parse::<f64>().ok()?;
|
||||
let b = parts[1].parse::<f64>().ok()?;
|
||||
|
||||
Some((a, b))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_validate_non_empty_string() {
|
||||
let result = ContractValidator::validate(&json!("hello"), "String | std.string.NonEmpty");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!(""), "String | std.string.NonEmpty");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_min_length() {
|
||||
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.min 3");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.min 3");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_max_length() {
|
||||
let result = ContractValidator::validate(&json!("hi"), "String | std.string.length.max 3");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!("hello"), "String | std.string.length.max 3");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_between() {
|
||||
let result = ContractValidator::validate(&json!(50), "Number | std.number.between 0 100");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!(150), "Number | std.number.between 0 100");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_greater_than() {
|
||||
let result = ContractValidator::validate(&json!(50), "Number | std.number.greater_than 10");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!(5), "Number | std.number.greater_than 10");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_less_than() {
|
||||
let result = ContractValidator::validate(&json!(5), "Number | std.number.less_than 10");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = ContractValidator::validate(&json!(50), "Number | std.number.less_than 10");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_range() {
|
||||
let range = ContractValidator::extract_range("std.number.between 0 100");
|
||||
assert_eq!(range, Some((0.0, 100.0)));
|
||||
|
||||
let range = ContractValidator::extract_range("std.number.between 0.5 99.9");
|
||||
assert_eq!(range, Some((0.5, 99.9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_predicate_passes() {
|
||||
let result = ContractValidator::validate(&json!("anything"), "String | some.unknown.predicate");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
46
crates/typedialog-core/src/nickel/mod.rs
Normal file
46
crates/typedialog-core/src/nickel/mod.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Nickel integration for typedialog
|
||||
//!
|
||||
//! This module provides bidirectional integration between Nickel configuration schemas
|
||||
//! and typedialog interactive forms:
|
||||
//!
|
||||
//! 1. **Nickel → Form**: Extract metadata from Nickel schemas and generate TOML forms
|
||||
//! 2. **Form Execution**: Run interactive forms (existing typedialog functionality)
|
||||
//! 3. **Results → Nickel**: Serialize form results back to valid Nickel with contracts
|
||||
//! 4. **Template Metaprogramming**: Render .ncl.j2 templates to generate Nickel code
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use typedialog::nickel::NickelCli;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! // Verify nickel CLI is available
|
||||
//! NickelCli::verify()?;
|
||||
//!
|
||||
//! // Extract metadata from schema
|
||||
//! let metadata = NickelCli::query(Path::new("schema.ncl"), Some("inputs"))?;
|
||||
//!
|
||||
//! // Parse into intermediate representation
|
||||
//! let schema_ir = MetadataParser::parse(metadata)?;
|
||||
//!
|
||||
//! // Generate TOML form
|
||||
//! let form = TomlGenerator::generate(&schema_ir)?;
|
||||
//! ```
|
||||
|
||||
pub mod cli;
|
||||
pub mod schema_ir;
|
||||
pub mod parser;
|
||||
pub mod toml_generator;
|
||||
pub mod serializer;
|
||||
pub mod template_engine;
|
||||
pub mod contracts;
|
||||
pub mod types;
|
||||
|
||||
pub use cli::NickelCli;
|
||||
pub use schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
pub use parser::MetadataParser;
|
||||
pub use toml_generator::TomlGenerator;
|
||||
pub use serializer::NickelSerializer;
|
||||
pub use template_engine::TemplateEngine;
|
||||
pub use contracts::ContractValidator;
|
||||
pub use types::TypeMapper;
|
||||
254
crates/typedialog-core/src/nickel/parser.rs
Normal file
254
crates/typedialog-core/src/nickel/parser.rs
Normal file
@ -0,0 +1,254 @@
|
||||
//! Metadata Parser
|
||||
//!
|
||||
//! Parses JSON output from `nickel query` into NickelSchemaIR.
|
||||
//!
|
||||
//! Handles extraction of metadata from Nickel schemas including:
|
||||
//! - Type contracts and annotations
|
||||
//! - Documentation (| doc)
|
||||
//! - Default values (| default)
|
||||
//! - Optional fields (| optional)
|
||||
//! - Nested record structures
|
||||
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
|
||||
/// Parser for Nickel metadata JSON from `nickel query` output
|
||||
pub struct MetadataParser;
|
||||
|
||||
impl MetadataParser {
|
||||
/// Parse JSON metadata from nickel query into NickelSchemaIR
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `json` - JSON value from `nickel query --format json`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// NickelSchemaIR with parsed fields, types, and metadata
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if JSON structure is invalid or required fields are missing
|
||||
pub fn parse(json: Value) -> Result<NickelSchemaIR> {
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
"Expected JSON object from nickel query",
|
||||
))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
Self::extract_fields(obj, Vec::new(), &mut fields)?;
|
||||
|
||||
Ok(NickelSchemaIR {
|
||||
name: "generated_schema".to_string(),
|
||||
description: None,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively extract fields from JSON object
|
||||
fn extract_fields(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
path: Vec<String>,
|
||||
fields: &mut Vec<NickelFieldIR>,
|
||||
) -> Result<()> {
|
||||
for (key, value) in obj {
|
||||
let mut field_path = path.clone();
|
||||
field_path.push(key.clone());
|
||||
|
||||
// For nested objects, recursively extract leaf fields
|
||||
if let Value::Object(nested_obj) = value {
|
||||
Self::extract_fields(nested_obj, field_path, fields)?;
|
||||
} else {
|
||||
// Parse field metadata and type
|
||||
let field = Self::parse_field_value(key, value, field_path)?;
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a single field from its JSON value
|
||||
fn parse_field_value(
|
||||
_name: &str,
|
||||
value: &Value,
|
||||
path: Vec<String>,
|
||||
) -> Result<NickelFieldIR> {
|
||||
let flat_name = path.join("_");
|
||||
|
||||
// Extract metadata from the value
|
||||
let doc = Self::extract_doc(value);
|
||||
let default = Self::extract_default(value);
|
||||
let optional = Self::extract_optional(value);
|
||||
let contract = Self::extract_contract(value);
|
||||
let nickel_type = Self::infer_type(value, &path);
|
||||
|
||||
Ok(NickelFieldIR {
|
||||
path,
|
||||
flat_name,
|
||||
nickel_type,
|
||||
doc,
|
||||
default,
|
||||
optional,
|
||||
contract,
|
||||
group: None, // Will be assigned during form generation
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract documentation from field value
|
||||
fn extract_doc(value: &Value) -> Option<String> {
|
||||
if let Value::Object(obj) = value {
|
||||
// Look for doc metadata
|
||||
if let Some(Value::String(doc)) = obj.get("doc") {
|
||||
return Some(doc.clone());
|
||||
}
|
||||
// Check metadata field
|
||||
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
|
||||
if let Some(Value::String(doc)) = meta_obj.get("doc") {
|
||||
return Some(doc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract default value from field value
|
||||
fn extract_default(value: &Value) -> Option<serde_json::Value> {
|
||||
if let Value::Object(obj) = value {
|
||||
// Look for default value
|
||||
if let Some(default) = obj.get("default") {
|
||||
return Some(default.clone());
|
||||
}
|
||||
// Check metadata field
|
||||
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
|
||||
if let Some(default) = meta_obj.get("default") {
|
||||
return Some(default.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract optional flag from field value
|
||||
fn extract_optional(value: &Value) -> bool {
|
||||
if let Value::Object(obj) = value {
|
||||
// Check optional flag
|
||||
if let Some(Value::Bool(opt)) = obj.get("optional") {
|
||||
return *opt;
|
||||
}
|
||||
// Check metadata field
|
||||
if let Some(Value::Object(meta_obj)) = obj.get("metadata") {
|
||||
if let Some(Value::Bool(opt)) = meta_obj.get("optional") {
|
||||
return *opt;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract Nickel contract/predicate from field value
|
||||
fn extract_contract(value: &Value) -> Option<String> {
|
||||
if let Value::Object(obj) = value {
|
||||
// Look for contract annotation
|
||||
if let Some(Value::String(contract)) = obj.get("contract") {
|
||||
return Some(contract.clone());
|
||||
}
|
||||
// Check type field for contract info
|
||||
if let Some(Value::String(type_str)) = obj.get("type") {
|
||||
if type_str.contains("|") {
|
||||
return Some(type_str.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Infer Nickel type from JSON value
|
||||
fn infer_type(value: &Value, _path: &[String]) -> NickelType {
|
||||
match value {
|
||||
Value::Null => NickelType::Custom("unknown".to_string()),
|
||||
Value::Bool(_) => NickelType::Bool,
|
||||
Value::Number(_) => NickelType::Number,
|
||||
Value::String(_) => NickelType::String,
|
||||
Value::Array(arr) => {
|
||||
// Infer array element type from first element
|
||||
if let Some(elem) = arr.first() {
|
||||
let elem_type = Self::infer_type(elem, &[]);
|
||||
NickelType::Array(Box::new(elem_type))
|
||||
} else {
|
||||
NickelType::Array(Box::new(NickelType::Custom("unknown".to_string())))
|
||||
}
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
// For nested objects, extract fields
|
||||
let mut nested_fields = Vec::new();
|
||||
let path_copy = _path.to_vec();
|
||||
let _ = Self::extract_fields(obj, path_copy, &mut nested_fields);
|
||||
NickelType::Record(nested_fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_field() {
|
||||
let json = json!({
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"age": 30
|
||||
}
|
||||
});
|
||||
|
||||
let result = MetadataParser::parse(json);
|
||||
assert!(result.is_ok());
|
||||
let schema = result.unwrap();
|
||||
// Flattened: user_name and user_age
|
||||
assert_eq!(schema.fields.len(), 2);
|
||||
let flat_names: Vec<_> = schema.fields.iter().map(|f| f.flat_name.as_str()).collect();
|
||||
assert!(flat_names.contains(&"user_name"));
|
||||
assert!(flat_names.contains(&"user_age"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_optional() {
|
||||
let json = json!({
|
||||
"settings": {
|
||||
"email": "test@example.com"
|
||||
}
|
||||
});
|
||||
|
||||
let result = MetadataParser::parse(json);
|
||||
assert!(result.is_ok());
|
||||
let schema = result.unwrap();
|
||||
// Should have settings_email field
|
||||
assert_eq!(schema.fields.len(), 1);
|
||||
assert_eq!(schema.fields[0].flat_name, "settings_email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_doc() {
|
||||
let value = json!({
|
||||
"doc": "Test documentation"
|
||||
});
|
||||
let doc = MetadataParser::extract_doc(&value);
|
||||
assert_eq!(doc, Some("Test documentation".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_default() {
|
||||
let value = json!({
|
||||
"default": 42
|
||||
});
|
||||
let default = MetadataParser::extract_default(&value);
|
||||
assert_eq!(default, Some(json!(42)));
|
||||
}
|
||||
}
|
||||
108
crates/typedialog-core/src/nickel/schema_ir.rs
Normal file
108
crates/typedialog-core/src/nickel/schema_ir.rs
Normal file
@ -0,0 +1,108 @@
|
||||
//! Nickel Schema Intermediate Representation
|
||||
//!
|
||||
//! Defines the normalized representation of a Nickel schema extracted from
|
||||
//! `nickel query` output, independent of the Nickel AST.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Intermediate representation of a Nickel schema
|
||||
///
|
||||
/// Contains the parsed structure of a Nickel configuration schema including
|
||||
/// field definitions, types, and metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NickelSchemaIR {
|
||||
/// Schema name/identifier
|
||||
pub name: String,
|
||||
|
||||
/// Optional description from | doc
|
||||
pub description: Option<String>,
|
||||
|
||||
/// All fields in the schema
|
||||
pub fields: Vec<NickelFieldIR>,
|
||||
}
|
||||
|
||||
/// Intermediate representation of a single field in a Nickel schema
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NickelFieldIR {
|
||||
/// Path to field: ["user", "name"] for user.name
|
||||
pub path: Vec<String>,
|
||||
|
||||
/// Flattened name: "user_name" for user.name
|
||||
pub flat_name: String,
|
||||
|
||||
/// Nickel type information
|
||||
pub nickel_type: NickelType,
|
||||
|
||||
/// Documentation from | doc
|
||||
pub doc: Option<String>,
|
||||
|
||||
/// Default value from | default
|
||||
pub default: Option<serde_json::Value>,
|
||||
|
||||
/// Whether field is optional (| optional)
|
||||
pub optional: bool,
|
||||
|
||||
/// Nickel contract/predicate (e.g., "std.string.NonEmpty")
|
||||
pub contract: Option<String>,
|
||||
|
||||
/// Semantic grouping for form UI
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// Nickel type information for a field
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum NickelType {
|
||||
/// String type
|
||||
String,
|
||||
|
||||
/// Number type
|
||||
Number,
|
||||
|
||||
/// Boolean type
|
||||
Bool,
|
||||
|
||||
/// Array type with element type
|
||||
Array(Box<NickelType>),
|
||||
|
||||
/// Nested record with fields
|
||||
Record(Vec<NickelFieldIR>),
|
||||
|
||||
/// Custom/unknown type
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl NickelSchemaIR {
|
||||
/// Create a new schema with given name and fields
|
||||
pub fn new(name: String, fields: Vec<NickelFieldIR>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description: None,
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a field by its flat name
|
||||
pub fn find_field(&self, flat_name: &str) -> Option<&NickelFieldIR> {
|
||||
self.fields.iter().find(|f| f.flat_name == flat_name)
|
||||
}
|
||||
|
||||
/// Get all fields in a specific group
|
||||
pub fn fields_by_group(&self, group: &str) -> Vec<&NickelFieldIR> {
|
||||
self.fields
|
||||
.iter()
|
||||
.filter(|f| f.group.as_deref() == Some(group))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all unique groups in the schema
|
||||
pub fn groups(&self) -> Vec<String> {
|
||||
let mut groups: Vec<_> = self
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| f.group.clone())
|
||||
.collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
groups
|
||||
}
|
||||
}
|
||||
356
crates/typedialog-core/src/nickel/serializer.rs
Normal file
356
crates/typedialog-core/src/nickel/serializer.rs
Normal file
@ -0,0 +1,356 @@
|
||||
//! Nickel Serializer
|
||||
//!
|
||||
//! Serializes form results back to valid Nickel code with contracts and metadata.
|
||||
//!
|
||||
//! Handles:
|
||||
//! - Unflattening flat field names back to nested record structure
|
||||
//! - Type annotations (String, Number, Bool, Array)
|
||||
//! - Nickel contracts and predicates
|
||||
//! - Documentation comments from original schema
|
||||
//! - Pretty printing with proper indentation
|
||||
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
|
||||
/// Serializer for converting form results to Nickel output
|
||||
pub struct NickelSerializer;
|
||||
|
||||
impl NickelSerializer {
|
||||
/// Serialize HashMap results to Nickel output with contracts and metadata
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `results` - HashMap of field names to values from form execution
|
||||
/// * `schema` - The Nickel schema IR with field metadata
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Valid Nickel code as string
|
||||
pub fn serialize(
|
||||
results: &HashMap<String, Value>,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Result<String> {
|
||||
// Build nested structure from flat results
|
||||
let nested = Self::unflatten_results(results, schema);
|
||||
|
||||
// Serialize to Nickel with contracts and docs
|
||||
let nickel_string = Self::serialize_value(&nested, schema, 0);
|
||||
|
||||
Ok(nickel_string)
|
||||
}
|
||||
|
||||
/// Unflatten HashMap results into nested structure
|
||||
fn unflatten_results(
|
||||
results: &HashMap<String, Value>,
|
||||
schema: &NickelSchemaIR,
|
||||
) -> Value {
|
||||
let mut root = serde_json::json!({});
|
||||
|
||||
for field in &schema.fields {
|
||||
if let Some(value) = results.get(&field.flat_name) {
|
||||
// Navigate/create nested structure and insert value
|
||||
Self::insert_nested(&mut root, &field.path, value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
/// Insert a value into nested structure using path
|
||||
fn insert_nested(obj: &mut Value, path: &[String], value: Value) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if path.len() == 1 {
|
||||
obj[&path[0]] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure intermediate objects exist
|
||||
for i in 0..path.len() - 1 {
|
||||
if !obj[&path[i]].is_object() {
|
||||
obj[&path[i]] = serde_json::json!({});
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to parent and insert
|
||||
let mut current = obj;
|
||||
for i in 0..path.len() - 1 {
|
||||
current = &mut current[&path[i]];
|
||||
}
|
||||
|
||||
current[&path[path.len() - 1]] = value;
|
||||
}
|
||||
|
||||
/// Serialize a value to Nickel code with type annotations
|
||||
fn serialize_value(value: &Value, schema: &NickelSchemaIR, indent: usize) -> String {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let indent_str = " ".repeat(indent);
|
||||
let inner_indent_str = " ".repeat(indent + 1);
|
||||
|
||||
if map.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
let mut lines = vec!["{\n".to_string()];
|
||||
|
||||
for (key, val) in map {
|
||||
// Find field metadata for type annotation
|
||||
if let Some(field_meta) = Self::find_field_for_key(schema, key) {
|
||||
// Add doc comment if present
|
||||
if let Some(doc) = &field_meta.doc {
|
||||
lines.push(format!("{}# {}\n", inner_indent_str, doc));
|
||||
}
|
||||
|
||||
// Build field with type annotation and contract
|
||||
let type_annotation = Self::build_type_annotation(&field_meta);
|
||||
let contract = field_meta.contract.as_ref().map(|c| format!(" | {}", c)).unwrap_or_default();
|
||||
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1);
|
||||
let field_line = if field_value.contains('\n') {
|
||||
format!("{}{} : {}{} = {}", inner_indent_str, key, type_annotation, contract, field_value)
|
||||
} else {
|
||||
format!("{}{} : {}{} = {},\n", inner_indent_str, key, type_annotation, contract, field_value)
|
||||
};
|
||||
lines.push(field_line);
|
||||
} else {
|
||||
// Fallback without metadata
|
||||
let field_value = Self::serialize_value(val, schema, indent + 1);
|
||||
let field_line = format!("{}{} = {},\n", inner_indent_str, key, field_value);
|
||||
lines.push(field_line);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!("{}}}", indent_str));
|
||||
lines.join("")
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let indent_str = " ".repeat(indent);
|
||||
let inner_indent_str = " ".repeat(indent + 1);
|
||||
|
||||
if arr.is_empty() {
|
||||
return "[]".to_string();
|
||||
}
|
||||
|
||||
let mut lines = vec!["[\n".to_string()];
|
||||
for item in arr {
|
||||
let item_str = Self::serialize_value(item, schema, indent + 1);
|
||||
lines.push(format!("{}{},\n", inner_indent_str, item_str));
|
||||
}
|
||||
lines.push(format!("{}]", indent_str));
|
||||
lines.join("")
|
||||
}
|
||||
Value::String(s) => format!("\"{}\"", escape_string(s)),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build type annotation from field metadata
|
||||
fn build_type_annotation(field: &NickelFieldIR) -> String {
|
||||
match &field.nickel_type {
|
||||
NickelType::String => "String".to_string(),
|
||||
NickelType::Number => "Number".to_string(),
|
||||
NickelType::Bool => "Bool".to_string(),
|
||||
NickelType::Array(elem_type) => {
|
||||
let elem_annotation = Self::type_to_nickel(elem_type);
|
||||
format!("[{}]", elem_annotation)
|
||||
}
|
||||
NickelType::Record(_) => "{...}".to_string(),
|
||||
NickelType::Custom(name) => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert NickelType to Nickel type annotation
|
||||
fn type_to_nickel(nickel_type: &NickelType) -> String {
|
||||
match nickel_type {
|
||||
NickelType::String => "String".to_string(),
|
||||
NickelType::Number => "Number".to_string(),
|
||||
NickelType::Bool => "Bool".to_string(),
|
||||
NickelType::Array(elem) => {
|
||||
format!("[{}]", Self::type_to_nickel(elem))
|
||||
}
|
||||
NickelType::Record(_) => "{...}".to_string(),
|
||||
NickelType::Custom(name) => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find field metadata for a key
|
||||
fn find_field_for_key(schema: &NickelSchemaIR, key: &str) -> Option<NickelFieldIR> {
|
||||
schema.fields.iter().find(|f| f.path.last().is_some_and(|p| p == key)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape string for Nickel double quotes
|
||||
fn escape_string(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_serialize_simple_values() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("name".to_string(), json!("Alice"));
|
||||
results.insert("age".to_string(), json!(30));
|
||||
results.insert("active".to_string(), json!(true));
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["name".to_string()],
|
||||
flat_name: "name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User name".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: Some("String | std.string.NonEmpty".to_string()),
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["age".to_string()],
|
||||
flat_name: "age".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["active".to_string()],
|
||||
flat_name: "active".to_string(),
|
||||
nickel_type: NickelType::Bool,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let output = NickelSerializer::serialize(&results, &schema).unwrap();
|
||||
|
||||
// Check that output contains key information
|
||||
assert!(output.contains("name"));
|
||||
assert!(output.contains("Alice"));
|
||||
assert!(output.contains("age"));
|
||||
assert!(output.contains("30"));
|
||||
assert!(output.contains("active"));
|
||||
assert!(output.contains("true"));
|
||||
assert!(output.contains("String | std.string.NonEmpty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unflatten_nested() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("user_name".to_string(), json!("Alice"));
|
||||
results.insert("user_email".to_string(), json!("alice@example.com"));
|
||||
results.insert("settings_theme".to_string(), json!("dark"));
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["user".to_string(), "name".to_string()],
|
||||
flat_name: "user_name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["user".to_string(), "email".to_string()],
|
||||
flat_name: "user_email".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["settings".to_string(), "theme".to_string()],
|
||||
flat_name: "settings_theme".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let output = NickelSerializer::serialize(&results, &schema).unwrap();
|
||||
|
||||
// Check nested structure is created
|
||||
assert!(output.contains("user"));
|
||||
assert!(output.contains("settings"));
|
||||
assert!(output.contains("Alice"));
|
||||
assert!(output.contains("alice@example.com"));
|
||||
assert!(output.contains("dark"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_string() {
|
||||
assert_eq!(escape_string("hello"), "hello");
|
||||
assert_eq!(escape_string("hello\"world"), "hello\\\"world");
|
||||
assert_eq!(escape_string("hello\\world"), "hello\\\\world");
|
||||
assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_to_nickel() {
|
||||
let string_type = NickelSerializer::type_to_nickel(&NickelType::String);
|
||||
assert_eq!(string_type, "String");
|
||||
|
||||
let array_type = NickelSerializer::type_to_nickel(&NickelType::Array(Box::new(NickelType::String)));
|
||||
assert_eq!(array_type, "[String]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_with_arrays() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("tags".to_string(), json!(["rust", "nickel", "forms"]));
|
||||
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["tags".to_string()],
|
||||
flat_name: "tags".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: None,
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let output = NickelSerializer::serialize(&results, &schema).unwrap();
|
||||
|
||||
assert!(output.contains("rust"));
|
||||
assert!(output.contains("nickel"));
|
||||
assert!(output.contains("forms"));
|
||||
assert!(output.contains("["));
|
||||
assert!(output.contains("]"));
|
||||
}
|
||||
}
|
||||
207
crates/typedialog-core/src/nickel/template_engine.rs
Normal file
207
crates/typedialog-core/src/nickel/template_engine.rs
Normal file
@ -0,0 +1,207 @@
|
||||
//! Template Engine
|
||||
//!
|
||||
//! Renders Tera templates for Nickel metaprogramming (.ncl.j2 files).
|
||||
//!
|
||||
//! Supports:
|
||||
//! - Loading and rendering .ncl.j2 templates
|
||||
//! - Template loops, conditionals, and filters
|
||||
//! - Passing form results as context to templates
|
||||
|
||||
use crate::error::{Error, ErrorKind};
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
use tera::{Tera, Context};
|
||||
|
||||
/// Template engine for rendering .ncl.j2 templates
|
||||
pub struct TemplateEngine {
|
||||
#[cfg(feature = "templates")]
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
/// Create a new template engine
|
||||
pub fn new() -> Self {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
let tera = Tera::default();
|
||||
TemplateEngine { tera }
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "templates"))]
|
||||
{
|
||||
TemplateEngine {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a template file with given values
|
||||
pub fn render_file(
|
||||
&mut self,
|
||||
template_path: &Path,
|
||||
values: &HashMap<String, Value>,
|
||||
) -> Result<String> {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
// Read template file
|
||||
let template_content = fs::read_to_string(template_path)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::Io,
|
||||
format!("Failed to read template file: {}", e),
|
||||
))?;
|
||||
|
||||
// Add template to engine
|
||||
let template_name = template_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("template");
|
||||
|
||||
self.tera.add_raw_template(template_name, &template_content)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
))?;
|
||||
|
||||
// Build context from values
|
||||
let mut context = Context::new();
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
}
|
||||
|
||||
// Render template
|
||||
self.tera.render(template_name, &context)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to render template: {}", e),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "templates"))]
|
||||
{
|
||||
Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
"Template feature not enabled. Enable with --features templates".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a template string with given values
|
||||
pub fn render_str(
|
||||
&mut self,
|
||||
template: &str,
|
||||
values: &HashMap<String, Value>,
|
||||
) -> Result<String> {
|
||||
#[cfg(feature = "templates")]
|
||||
{
|
||||
// Add template to engine
|
||||
self.tera.add_raw_template("inline", template)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to add template: {}", e),
|
||||
))?;
|
||||
|
||||
// Build context from values
|
||||
let mut context = Context::new();
|
||||
for (key, value) in values {
|
||||
context.insert(key, value);
|
||||
}
|
||||
|
||||
// Render template
|
||||
self.tera.render("inline", &context)
|
||||
.map_err(|e| Error::new(
|
||||
ErrorKind::ValidationFailed,
|
||||
format!("Failed to render template: {}", e),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "templates"))]
|
||||
{
|
||||
Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
"Template feature not enabled. Enable with --features templates".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
#[test]
|
||||
fn test_template_engine_new() {
|
||||
let _engine = TemplateEngine::new();
|
||||
// Just verify it can be created
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
#[test]
|
||||
fn test_render_simple_template() {
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
values.insert("name".to_string(), json!("Alice"));
|
||||
values.insert("age".to_string(), json!(30));
|
||||
|
||||
let template = r#"
|
||||
name : String = "{{ name }}"
|
||||
age : Number = {{ age }}
|
||||
"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Alice"));
|
||||
assert!(output.contains("30"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
#[test]
|
||||
fn test_render_with_loop() {
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
let tags = vec![json!("rust"), json!("nickel"), json!("forms")];
|
||||
values.insert("tags".to_string(), Value::Array(tags));
|
||||
|
||||
let template = r#"tags = [
|
||||
{% for tag in tags %}
|
||||
"{{ tag }}",
|
||||
{% endfor %}
|
||||
]"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("rust"));
|
||||
assert!(output.contains("nickel"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "templates")]
|
||||
#[test]
|
||||
fn test_render_with_conditional() {
|
||||
let mut engine = TemplateEngine::new();
|
||||
let mut values = HashMap::new();
|
||||
values.insert("enabled".to_string(), json!(true));
|
||||
values.insert("feature".to_string(), json!("monitoring"));
|
||||
|
||||
let template = r#"{% if enabled %}
|
||||
{{ feature }}_enabled : Bool = true
|
||||
{% endif %}"#;
|
||||
|
||||
let result = engine.render_str(template, &values);
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("monitoring_enabled"));
|
||||
}
|
||||
}
|
||||
413
crates/typedialog-core/src/nickel/toml_generator.rs
Normal file
413
crates/typedialog-core/src/nickel/toml_generator.rs
Normal file
@ -0,0 +1,413 @@
|
||||
//! TOML Form Generator
|
||||
//!
|
||||
//! Converts Nickel schema intermediate representation (NickelSchemaIR)
|
||||
//! into typedialog FormDefinition TOML format.
|
||||
//!
|
||||
//! Handles type mapping, metadata extraction, flatten/unflatten operations,
|
||||
//! and semantic grouping for form UI organization.
|
||||
|
||||
use super::schema_ir::{NickelSchemaIR, NickelFieldIR, NickelType};
|
||||
use crate::form_parser::{FormDefinition, FieldDefinition, FieldType, DisplayItem};
|
||||
use crate::error::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generator for converting Nickel schemas to typedialog TOML forms
|
||||
pub struct TomlGenerator;
|
||||
|
||||
impl TomlGenerator {
|
||||
/// Convert a Nickel schema IR to a typedialog FormDefinition
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `schema` - The Nickel schema intermediate representation
|
||||
/// * `flatten_records` - Whether to flatten nested records into flat field names
|
||||
/// * `use_groups` - Whether to use semantic grouping for form organization
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// FormDefinition ready to be serialized to TOML
|
||||
pub fn generate(
|
||||
schema: &NickelSchemaIR,
|
||||
flatten_records: bool,
|
||||
use_groups: bool,
|
||||
) -> Result<FormDefinition> {
|
||||
let mut fields = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
let mut group_order: HashMap<String, usize> = HashMap::new();
|
||||
let mut current_order = 0;
|
||||
|
||||
// First pass: collect all groups
|
||||
if use_groups {
|
||||
for field in &schema.fields {
|
||||
if let Some(group) = &field.group {
|
||||
group_order.entry(group.clone()).or_insert_with(|| {
|
||||
let order = current_order;
|
||||
current_order += 1;
|
||||
order
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate display items for groups (headers)
|
||||
let mut item_order = 0;
|
||||
if use_groups {
|
||||
for group in &schema.fields.iter().filter_map(|f| f.group.as_ref()).collect::<std::collections::HashSet<_>>() {
|
||||
items.push(DisplayItem {
|
||||
name: format!("{}_header", group),
|
||||
item_type: "section".to_string(),
|
||||
title: Some(format_group_title(group)),
|
||||
border_top: Some(true),
|
||||
group: Some(group.to_string()),
|
||||
order: item_order,
|
||||
content: None,
|
||||
template: None,
|
||||
border_bottom: None,
|
||||
margin_left: None,
|
||||
border_margin_left: None,
|
||||
content_margin_left: None,
|
||||
align: None,
|
||||
when: None,
|
||||
includes: None,
|
||||
border_top_char: None,
|
||||
border_top_len: None,
|
||||
border_top_l: None,
|
||||
border_top_r: None,
|
||||
border_bottom_char: None,
|
||||
border_bottom_len: None,
|
||||
border_bottom_l: None,
|
||||
border_bottom_r: None,
|
||||
i18n: None,
|
||||
});
|
||||
item_order += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: generate fields
|
||||
let mut field_order = item_order + 100; // Offset to allow items to display first
|
||||
for field in &schema.fields {
|
||||
let form_field = Self::field_ir_to_definition(
|
||||
field,
|
||||
flatten_records,
|
||||
field_order,
|
||||
)?;
|
||||
fields.push(form_field);
|
||||
field_order += 1;
|
||||
}
|
||||
|
||||
Ok(FormDefinition {
|
||||
name: schema.name.clone(),
|
||||
description: schema.description.clone(),
|
||||
fields,
|
||||
items,
|
||||
locale: None,
|
||||
template: None,
|
||||
output_template: None,
|
||||
i18n_prefix: None,
|
||||
display_mode: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a single NickelFieldIR to a FieldDefinition
|
||||
fn field_ir_to_definition(
|
||||
field: &NickelFieldIR,
|
||||
_flatten_records: bool,
|
||||
order: usize,
|
||||
) -> Result<FieldDefinition> {
|
||||
let (field_type, custom_type) = Self::nickel_type_to_field_type(&field.nickel_type)?;
|
||||
|
||||
let prompt = field
|
||||
.doc
|
||||
.clone()
|
||||
.unwrap_or_else(|| format_prompt_from_path(&field.flat_name));
|
||||
|
||||
let default = field.default.as_ref().map(|v| {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
});
|
||||
|
||||
let options = match &field.nickel_type {
|
||||
NickelType::Array(_) => {
|
||||
// Try to extract enum options from array element type or doc
|
||||
Self::extract_enum_options(field)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Determine if field is required
|
||||
let required = if field.optional {
|
||||
Some(false)
|
||||
} else {
|
||||
Some(true)
|
||||
};
|
||||
|
||||
Ok(FieldDefinition {
|
||||
name: field.flat_name.clone(),
|
||||
field_type,
|
||||
prompt,
|
||||
default,
|
||||
placeholder: None,
|
||||
options,
|
||||
required,
|
||||
file_extension: None,
|
||||
prefix_text: None,
|
||||
page_size: None,
|
||||
vim_mode: None,
|
||||
custom_type,
|
||||
min_date: None,
|
||||
max_date: None,
|
||||
week_start: None,
|
||||
order,
|
||||
when: None,
|
||||
i18n: None,
|
||||
group: field.group.clone(),
|
||||
nickel_contract: field.contract.clone(),
|
||||
nickel_path: Some(field.path.clone()),
|
||||
nickel_doc: field.doc.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a Nickel type to typedialog field type
|
||||
fn nickel_type_to_field_type(nickel_type: &NickelType) -> Result<(FieldType, Option<String>)> {
|
||||
match nickel_type {
|
||||
NickelType::String => Ok((FieldType::Text, None)),
|
||||
NickelType::Number => Ok((FieldType::Custom, Some("f64".to_string()))),
|
||||
NickelType::Bool => Ok((FieldType::Confirm, None)),
|
||||
NickelType::Array(_) => {
|
||||
// Default to editor for JSON array editing; can be changed to select/multiselect
|
||||
// if options are detected
|
||||
Ok((FieldType::Editor, Some("json".to_string())))
|
||||
}
|
||||
NickelType::Record(_) => {
|
||||
// Records are handled by nested field generation
|
||||
Ok((FieldType::Text, None))
|
||||
}
|
||||
NickelType::Custom(type_name) => {
|
||||
// Unknown types map to custom with type name
|
||||
Ok((FieldType::Custom, Some(type_name.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract enum options from field documentation or array structure
|
||||
fn extract_enum_options(field: &NickelFieldIR) -> Option<Vec<String>> {
|
||||
// Check if doc contains "Options: X, Y, Z" pattern
|
||||
if let Some(doc) = &field.doc {
|
||||
if let Some(start) = doc.find("Options:") {
|
||||
let options_str = &doc[start + 8..]; // Skip "Options:"
|
||||
let options: Vec<String> = options_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !options.is_empty() {
|
||||
return Some(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, don't try to extract from array structure unless we have more info
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a group title from group name
|
||||
fn format_group_title(group: &str) -> String {
|
||||
// Convert snake_case or kebab-case to Title Case
|
||||
group
|
||||
.split(['_', '-'])
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Format a prompt from field name
|
||||
fn format_prompt_from_path(flat_name: &str) -> String {
|
||||
// Convert snake_case to Title Case
|
||||
flat_name
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_generate_simple_schema() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "test_schema".to_string(),
|
||||
description: Some("A test schema".to_string()),
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["name".to_string()],
|
||||
flat_name: "name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User full name".to_string()),
|
||||
default: Some(json!("Alice")),
|
||||
optional: false,
|
||||
contract: Some("String | std.string.NonEmpty".to_string()),
|
||||
group: None,
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["age".to_string()],
|
||||
flat_name: "age".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: Some("User age".to_string()),
|
||||
default: None,
|
||||
optional: true,
|
||||
contract: None,
|
||||
group: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, false).unwrap();
|
||||
assert_eq!(form.name, "test_schema");
|
||||
assert_eq!(form.fields.len(), 2);
|
||||
|
||||
// Check first field
|
||||
assert_eq!(form.fields[0].name, "name");
|
||||
assert_eq!(form.fields[0].field_type, FieldType::Text);
|
||||
assert_eq!(form.fields[0].required, Some(true));
|
||||
assert_eq!(form.fields[0].nickel_contract, Some("String | std.string.NonEmpty".to_string()));
|
||||
|
||||
// Check second field
|
||||
assert_eq!(form.fields[1].name, "age");
|
||||
assert_eq!(form.fields[1].field_type, FieldType::Custom);
|
||||
assert_eq!(form.fields[1].custom_type, Some("f64".to_string()));
|
||||
assert_eq!(form.fields[1].required, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_groups() {
|
||||
let schema = NickelSchemaIR {
|
||||
name: "grouped_schema".to_string(),
|
||||
description: None,
|
||||
fields: vec![
|
||||
NickelFieldIR {
|
||||
path: vec!["user_name".to_string()],
|
||||
flat_name: "user_name".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("User name".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("user".to_string()),
|
||||
},
|
||||
NickelFieldIR {
|
||||
path: vec!["settings_theme".to_string()],
|
||||
flat_name: "settings_theme".to_string(),
|
||||
nickel_type: NickelType::String,
|
||||
doc: Some("Theme preference".to_string()),
|
||||
default: Some(json!("dark")),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: Some("settings".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let form = TomlGenerator::generate(&schema, false, true).unwrap();
|
||||
|
||||
// Should have display items for groups
|
||||
assert!(form.items.len() > 0);
|
||||
|
||||
// Check fields are grouped
|
||||
assert_eq!(form.fields[0].group, Some("user".to_string()));
|
||||
assert_eq!(form.fields[1].group, Some("settings".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nickel_type_to_field_type() {
|
||||
let (field_type, custom_type) =
|
||||
TomlGenerator::nickel_type_to_field_type(&NickelType::String).unwrap();
|
||||
assert_eq!(field_type, FieldType::Text);
|
||||
assert_eq!(custom_type, None);
|
||||
|
||||
let (field_type, custom_type) =
|
||||
TomlGenerator::nickel_type_to_field_type(&NickelType::Number).unwrap();
|
||||
assert_eq!(field_type, FieldType::Custom);
|
||||
assert_eq!(custom_type, Some("f64".to_string()));
|
||||
|
||||
let (field_type, custom_type) =
|
||||
TomlGenerator::nickel_type_to_field_type(&NickelType::Bool).unwrap();
|
||||
assert_eq!(field_type, FieldType::Confirm);
|
||||
assert_eq!(custom_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_group_title() {
|
||||
assert_eq!(format_group_title("user"), "User");
|
||||
assert_eq!(format_group_title("user_settings"), "User Settings");
|
||||
assert_eq!(format_group_title("api-config"), "Api Config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_prompt_from_path() {
|
||||
assert_eq!(format_prompt_from_path("name"), "Name");
|
||||
assert_eq!(format_prompt_from_path("user_name"), "User Name");
|
||||
assert_eq!(format_prompt_from_path("first_name"), "First Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_enum_options() {
|
||||
let field = NickelFieldIR {
|
||||
path: vec!["status".to_string()],
|
||||
flat_name: "status".to_string(),
|
||||
nickel_type: NickelType::Array(Box::new(NickelType::String)),
|
||||
doc: Some("Status. Options: pending, active, completed".to_string()),
|
||||
default: None,
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
};
|
||||
|
||||
let options = TomlGenerator::extract_enum_options(&field);
|
||||
assert_eq!(
|
||||
options,
|
||||
Some(vec![
|
||||
"pending".to_string(),
|
||||
"active".to_string(),
|
||||
"completed".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_value_conversion() {
|
||||
let field = NickelFieldIR {
|
||||
path: vec!["count".to_string()],
|
||||
flat_name: "count".to_string(),
|
||||
nickel_type: NickelType::Number,
|
||||
doc: None,
|
||||
default: Some(json!(42)),
|
||||
optional: false,
|
||||
contract: None,
|
||||
group: None,
|
||||
};
|
||||
|
||||
let form_field = TomlGenerator::field_ir_to_definition(&field, false, 0).unwrap();
|
||||
assert_eq!(form_field.default, Some("42".to_string()));
|
||||
}
|
||||
}
|
||||
16
crates/typedialog-core/src/nickel/types.rs
Normal file
16
crates/typedialog-core/src/nickel/types.rs
Normal file
@ -0,0 +1,16 @@
|
||||
//! Type Mapping
|
||||
//!
|
||||
//! Maps Nickel types to typedialog field types.
|
||||
|
||||
use super::schema_ir::NickelType;
|
||||
|
||||
/// Type mapper from Nickel types to typedialog field types
|
||||
pub struct TypeMapper;
|
||||
|
||||
impl TypeMapper {
|
||||
/// Map a Nickel type to a typedialog field type
|
||||
pub fn map_type(_nickel_type: &NickelType) -> String {
|
||||
// TODO: Implement in subsequent tasks
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
639
crates/typedialog-core/src/prompts.rs
Normal file
639
crates/typedialog-core/src/prompts.rs
Normal file
@ -0,0 +1,639 @@
|
||||
//! Interactive prompt functions
|
||||
//!
|
||||
//! Provides high-level functions for all prompt types with automatic
|
||||
//! fallback to stdin when interactive mode isn't available.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use chrono::{NaiveDate, Weekday};
|
||||
use inquire::{Confirm, DateSelect, Editor as InquireEditor, MultiSelect, Password, PasswordDisplayMode, Select, Text};
|
||||
use std::io::{Read, Write};
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Prompt for text input
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `default` - Optional default value
|
||||
/// * `placeholder` - Optional placeholder text
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let name = prompts::text("Enter name", None, None)?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn text(prompt: &str, default: Option<&str>, placeholder: Option<&str>) -> Result<String> {
|
||||
let mut text_prompt = Text::new(prompt);
|
||||
|
||||
if let Some(def) = default {
|
||||
text_prompt = text_prompt.with_default(def);
|
||||
}
|
||||
if let Some(ph) = placeholder {
|
||||
text_prompt = text_prompt.with_placeholder(ph);
|
||||
}
|
||||
|
||||
match text_prompt.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_text(prompt, default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for confirmation (yes/no)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `default` - Optional default value
|
||||
/// * `formatter` - Optional custom formatter (format: "true_text|false_text", e.g., "sim|não")
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let confirmed = prompts::confirm("Continue?", None, None)?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn confirm(prompt: &str, default: Option<bool>, _formatter: Option<&str>) -> Result<bool> {
|
||||
let mut confirm_prompt = Confirm::new(prompt);
|
||||
|
||||
if let Some(def) = default {
|
||||
confirm_prompt = confirm_prompt.with_default(def);
|
||||
}
|
||||
|
||||
// Apply default formatter based on inquire's standard
|
||||
// Custom formatter from CLI is handled in main.rs after getting the boolean result
|
||||
confirm_prompt = confirm_prompt
|
||||
.with_formatter(&|ans| match ans {
|
||||
true => "yes".to_owned(),
|
||||
false => "no".to_owned(),
|
||||
});
|
||||
|
||||
match confirm_prompt.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_confirm(prompt, default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for password input (masked by default)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `with_toggle` - Allow showing/hiding password with Ctrl+R
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let password = prompts::password("Enter password", false)?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn password(prompt: &str, with_toggle: bool) -> Result<String> {
|
||||
let mut password_prompt = Password::new(prompt)
|
||||
.with_display_mode(PasswordDisplayMode::Masked);
|
||||
|
||||
if with_toggle {
|
||||
password_prompt = password_prompt
|
||||
.with_display_toggle_enabled()
|
||||
.with_help_message("Press Ctrl+R to toggle password visibility");
|
||||
}
|
||||
|
||||
match password_prompt.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_password(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for single selection from a list
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `options` - List of options to choose from
|
||||
/// * `page_size` - Optional number of options per page
|
||||
/// * `vim_mode` - Enable vim mode navigation
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let choice = prompts::select(
|
||||
/// "Choose role",
|
||||
/// vec!["Admin".to_string(), "User".to_string()],
|
||||
/// None,
|
||||
/// false,
|
||||
/// )?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn select(
|
||||
prompt: &str,
|
||||
options: Vec<String>,
|
||||
page_size: Option<usize>,
|
||||
vim_mode: bool,
|
||||
) -> Result<String> {
|
||||
let mut select = Select::new(prompt, options);
|
||||
|
||||
if let Some(size) = page_size {
|
||||
select = select.with_page_size(size);
|
||||
}
|
||||
|
||||
if vim_mode {
|
||||
select = select.with_vim_mode(true);
|
||||
}
|
||||
|
||||
match select.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_select(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for multiple selections from a list
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `options` - List of options to choose from
|
||||
/// * `page_size` - Optional number of options per page
|
||||
/// * `vim_mode` - Enable vim mode navigation
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let choices = prompts::multi_select(
|
||||
/// "Choose services",
|
||||
/// vec!["nginx".to_string(), "postgres".to_string()],
|
||||
/// None,
|
||||
/// false,
|
||||
/// )?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn multi_select(
|
||||
prompt: &str,
|
||||
options: Vec<String>,
|
||||
page_size: Option<usize>,
|
||||
vim_mode: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut multi_select = MultiSelect::new(prompt, options);
|
||||
|
||||
if let Some(size) = page_size {
|
||||
multi_select = multi_select.with_page_size(size);
|
||||
}
|
||||
|
||||
if vim_mode {
|
||||
multi_select = multi_select.with_vim_mode(true);
|
||||
}
|
||||
|
||||
match multi_select.prompt() {
|
||||
Ok(results) => Ok(results),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_multi_select(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for text using an external editor
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `file_extension` - Optional file extension for the temp file (for syntax highlighting)
|
||||
/// * `default_content` - Optional default content (text or loaded from file)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// // With literal text template
|
||||
/// let text = prompts::editor("Enter description", Some("md"), Some("# Title\n\n## Section\n"))?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn editor(
|
||||
prompt: &str,
|
||||
file_extension: Option<&str>,
|
||||
prefix_text: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let mut editor = InquireEditor::new(prompt);
|
||||
|
||||
if let Some(ext) = file_extension {
|
||||
editor = editor.with_file_extension(ext);
|
||||
}
|
||||
|
||||
if let Some(text) = prefix_text {
|
||||
editor = editor.with_predefined_text(text);
|
||||
}
|
||||
|
||||
// Formatter to show truncated preview of submitted text
|
||||
editor = editor.with_formatter(&|text| {
|
||||
if text.is_empty() {
|
||||
"<skipped>".to_string()
|
||||
} else if text.len() <= 50 {
|
||||
text.to_string()
|
||||
} else {
|
||||
format!("{}...", &text[..47])
|
||||
}
|
||||
});
|
||||
|
||||
match editor.prompt() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using fallback editor");
|
||||
stdin_editor(prompt, prefix_text, file_extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for date selection using a calendar
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `default` - Optional default date (YYYY-MM-DD format)
|
||||
/// * `min_date` - Optional minimum date (YYYY-MM-DD format)
|
||||
/// * `max_date` - Optional maximum date (YYYY-MM-DD format)
|
||||
/// * `week_start` - Week start day (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let date = prompts::date("Select date", None, None, None, "Mon")?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn date(
|
||||
prompt: &str,
|
||||
default: Option<&str>,
|
||||
min_date: Option<&str>,
|
||||
max_date: Option<&str>,
|
||||
week_start: &str,
|
||||
) -> Result<String> {
|
||||
let mut date_select = DateSelect::new(prompt);
|
||||
|
||||
if let Some(def) = default {
|
||||
if let Ok(parsed) = NaiveDate::parse_from_str(def, "%Y-%m-%d") {
|
||||
date_select = date_select.with_default(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(min) = min_date {
|
||||
if let Ok(parsed) = NaiveDate::parse_from_str(min, "%Y-%m-%d") {
|
||||
date_select = date_select.with_min_date(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max) = max_date {
|
||||
if let Ok(parsed) = NaiveDate::parse_from_str(max, "%Y-%m-%d") {
|
||||
date_select = date_select.with_max_date(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse week start day
|
||||
let weekday = match week_start.to_lowercase().as_str() {
|
||||
"mon" => Weekday::Mon,
|
||||
"tue" => Weekday::Tue,
|
||||
"wed" => Weekday::Wed,
|
||||
"thu" => Weekday::Thu,
|
||||
"fri" => Weekday::Fri,
|
||||
"sat" => Weekday::Sat,
|
||||
"sun" => Weekday::Sun,
|
||||
_ => Weekday::Mon, // Default to Monday
|
||||
};
|
||||
|
||||
date_select = date_select.with_week_start(weekday);
|
||||
|
||||
match date_select.prompt() {
|
||||
Ok(result) => Ok(result.format("%Y-%m-%d").to_string()),
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_date(prompt, default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for custom type with validation
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt message
|
||||
/// * `type_name` - Type name (i32, u16, f64, ipv4, ipv6, etc.)
|
||||
/// * `default` - Optional default value
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use typedialog_core::prompts;
|
||||
/// let port = prompts::custom("Enter port", "u16", None)?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn custom(prompt: &str, type_name: &str, default: Option<&str>) -> Result<String> {
|
||||
let validator = |input: &str| match type_name {
|
||||
"i32" => input.parse::<i32>().is_ok(),
|
||||
"i64" => input.parse::<i64>().is_ok(),
|
||||
"u16" => input.parse::<u16>().is_ok(),
|
||||
"u32" => input.parse::<u32>().is_ok(),
|
||||
"u64" => input.parse::<u64>().is_ok(),
|
||||
"f32" => input.parse::<f32>().is_ok(),
|
||||
"f64" => input.parse::<f64>().is_ok(),
|
||||
"ipv4" => input.parse::<std::net::Ipv4Addr>().is_ok(),
|
||||
"ipv6" => input.parse::<std::net::Ipv6Addr>().is_ok(),
|
||||
_ => true,
|
||||
};
|
||||
|
||||
let placeholder_msg = format!("Expected type: {}", type_name);
|
||||
let mut text_prompt = Text::new(prompt);
|
||||
|
||||
if let Some(def) = default {
|
||||
text_prompt = text_prompt.with_default(def);
|
||||
}
|
||||
|
||||
text_prompt = text_prompt.with_placeholder(&placeholder_msg);
|
||||
|
||||
match text_prompt.prompt() {
|
||||
Ok(result) => {
|
||||
if validator(&result) {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(Error::validation_failed(format!(
|
||||
"Invalid input for type {}",
|
||||
type_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
eprintln!("Note: Interactive mode not available, using stdin input");
|
||||
stdin_custom(prompt, type_name, default, &validator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stdin Fallback Implementations
|
||||
// ============================================================================
|
||||
|
||||
fn stdin_text(prompt: &str, default: Option<&str>) -> Result<String> {
|
||||
println!("{}", prompt);
|
||||
if let Some(def) = default {
|
||||
print!("[{}]: ", def);
|
||||
} else {
|
||||
print!(": ");
|
||||
}
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.unwrap_or("").to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_confirm(prompt: &str, default: Option<bool>) -> Result<bool> {
|
||||
let default_str = match default {
|
||||
Some(true) => "Y/n",
|
||||
Some(false) => "y/N",
|
||||
None => "y/n",
|
||||
};
|
||||
|
||||
println!("{} [{}]: ", prompt, default_str);
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"y" | "yes" | "true" => Ok(true),
|
||||
"n" | "no" | "false" => Ok(false),
|
||||
"" => Ok(default.unwrap_or(false)),
|
||||
_ => Err(Error::validation_failed("Please answer yes or no")),
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_password(prompt: &str) -> Result<String> {
|
||||
// Try rpassword first (requires real TTY), fall back to simple read if not available
|
||||
let prompt_with_suffix = format!("{}: ", prompt);
|
||||
|
||||
match rpassword::prompt_password(&prompt_with_suffix) {
|
||||
Ok(pwd) => Ok(pwd),
|
||||
Err(_) => {
|
||||
// Fallback for piped input (no TTY available)
|
||||
// Show cursor at the beginning of line
|
||||
println!("{}", prompt);
|
||||
print!("> ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_select(prompt: &str) -> Result<String> {
|
||||
println!("{}", prompt);
|
||||
print!("Enter selection: ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
fn stdin_multi_select(prompt: &str) -> Result<Vec<String>> {
|
||||
println!("{}", prompt);
|
||||
print!("Enter selections (comma-separated): ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
let selections: Vec<String> = input
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(selections)
|
||||
}
|
||||
|
||||
fn stdin_editor(prompt: &str, prefix_text: Option<&str>, file_extension: Option<&str>) -> Result<String> {
|
||||
println!("{}", prompt);
|
||||
|
||||
// Try to open a temporary file with the editor
|
||||
if let Ok(content) = open_editor_with_temp_file(prefix_text, file_extension) {
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
// Fallback to stdin-based editing if temp file approach fails
|
||||
println!("\n[Unable to open editor, using stdin mode]");
|
||||
println!("Paste your content below (Ctrl+D or Ctrl+Z to finish):");
|
||||
|
||||
// Start with prefix text if provided
|
||||
let mut content = if let Some(text) = prefix_text {
|
||||
text.replace("\\n", "\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if !content.is_empty() {
|
||||
println!("\nPre-filled content:");
|
||||
println!("{}", content);
|
||||
println!();
|
||||
}
|
||||
|
||||
print!("> ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input)?;
|
||||
|
||||
// Combine prefix text with user input
|
||||
content.push_str(&input);
|
||||
|
||||
Ok(content.trim_end().to_string())
|
||||
}
|
||||
|
||||
/// Open a temporary file with the system editor
|
||||
fn open_editor_with_temp_file(
|
||||
prefix_text: Option<&str>,
|
||||
file_extension: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// Create temporary file with appropriate extension
|
||||
let mut temp_file = if let Some(ext) = file_extension {
|
||||
tempfile::Builder::new()
|
||||
.suffix(&format!(".{}", ext))
|
||||
.tempfile()
|
||||
.map_err(Error::io)?
|
||||
} else {
|
||||
NamedTempFile::new().map_err(Error::io)?
|
||||
};
|
||||
|
||||
// Write prefix text to temp file
|
||||
if let Some(text) = prefix_text {
|
||||
let content = text.replace("\\n", "\n");
|
||||
temp_file.write_all(content.as_bytes()).map_err(Error::io)?;
|
||||
temp_file.flush().map_err(Error::io)?;
|
||||
}
|
||||
|
||||
let path = temp_file.path().to_path_buf();
|
||||
|
||||
// Get editor command from environment, with sensible defaults
|
||||
let editor = std::env::var("EDITOR")
|
||||
.or_else(|_| std::env::var("VISUAL"))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(windows) {
|
||||
"notepad".to_string()
|
||||
} else {
|
||||
"nano".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
// Open the file with the editor
|
||||
let status = Command::new(&editor)
|
||||
.arg(&path)
|
||||
.status()
|
||||
.map_err(Error::io)?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(Error::validation_failed(format!(
|
||||
"Editor '{}' exited with error code",
|
||||
editor
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the edited content back
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(Error::io)?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn stdin_date(prompt: &str, default: Option<&str>) -> Result<String> {
|
||||
println!("{}", prompt);
|
||||
if let Some(def) = default {
|
||||
print!("[{}] (YYYY-MM-DD): ", def);
|
||||
} else {
|
||||
print!("(YYYY-MM-DD): ");
|
||||
}
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.unwrap_or("").to_string())
|
||||
} else {
|
||||
// Validate date format
|
||||
NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_custom<F>(
|
||||
prompt: &str,
|
||||
type_name: &str,
|
||||
default: Option<&str>,
|
||||
validator: &F,
|
||||
) -> Result<String>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
loop {
|
||||
println!("{}", prompt);
|
||||
if let Some(def) = default {
|
||||
print!("[{}]: ", def);
|
||||
} else {
|
||||
print!(": ");
|
||||
}
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
if let Some(def) = default {
|
||||
return Ok(def.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if validator(trimmed) {
|
||||
return Ok(trimmed.to_string());
|
||||
} else {
|
||||
eprintln!("Invalid input for type {}", type_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stdlib_integration() {
|
||||
// Just verify the types and signatures are correct
|
||||
let _: Result<String> = Ok("test".to_string());
|
||||
}
|
||||
}
|
||||
111
crates/typedialog-core/src/templates/context.rs
Normal file
111
crates/typedialog-core/src/templates/context.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Template context builder for Tera templates
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builder for constructing Tera template context
|
||||
pub struct TemplateContextBuilder {
|
||||
context: tera::Context,
|
||||
}
|
||||
|
||||
impl TemplateContextBuilder {
|
||||
/// Create a new empty context builder
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
context: tera::Context::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add form results to the context
|
||||
pub fn with_results(mut self, results: &HashMap<String, Value>) -> Self {
|
||||
for (key, value) in results {
|
||||
self.context.insert(key, value);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add form definition metadata
|
||||
pub fn with_form(mut self, form: &crate::FormDefinition) -> Self {
|
||||
self.context.insert("form_name", &form.name);
|
||||
if let Some(desc) = &form.description {
|
||||
self.context.insert("form_description", desc);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add environment variables
|
||||
pub fn with_env(mut self) -> Self {
|
||||
let mut env_vars = HashMap::new();
|
||||
for (key, value) in std::env::vars() {
|
||||
env_vars.insert(key, value);
|
||||
}
|
||||
self.context.insert("env", &env_vars);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add custom data with any serializable type
|
||||
pub fn with_data<T: Serialize>(mut self, key: &str, value: &T) -> Self {
|
||||
if let Ok(json_value) = serde_json::to_value(value) {
|
||||
self.context.insert(key, &json_value);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a timestamp (ISO 8601 format)
|
||||
pub fn with_timestamp(mut self) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
self.context.insert("now", &now);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build and return the context
|
||||
pub fn build(self) -> tera::Context {
|
||||
self.context
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateContextBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_context_builder_new() {
|
||||
let _context = TemplateContextBuilder::new().build();
|
||||
// Context created successfully
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_builder_with_data() {
|
||||
let _context = TemplateContextBuilder::new()
|
||||
.with_data("name", &"test".to_string())
|
||||
.build();
|
||||
// Context with data created successfully
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_builder_with_env() {
|
||||
let _context = TemplateContextBuilder::new().with_env().build();
|
||||
// Context with env created successfully
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_builder_chaining() {
|
||||
let mut results = HashMap::new();
|
||||
results.insert("username".to_string(), json!("alice"));
|
||||
|
||||
let _context = TemplateContextBuilder::new()
|
||||
.with_results(&results)
|
||||
.with_env()
|
||||
.with_timestamp()
|
||||
.build();
|
||||
// Chained context created successfully
|
||||
}
|
||||
}
|
||||
93
crates/typedialog-core/src/templates/filters.rs
Normal file
93
crates/typedialog-core/src/templates/filters.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! Custom Tera filters for templates
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tera::{Filter, Result as TeraResult, Value};
|
||||
|
||||
/// Filter for formatting dates
|
||||
///
|
||||
/// Usage: `{{ "2024-12-13" | date(format="%d/%m/%Y") }}`
|
||||
pub struct DateFormatFilter;
|
||||
|
||||
impl Filter for DateFormatFilter {
|
||||
fn filter(&self, value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
|
||||
let date_str = value
|
||||
.as_str()
|
||||
.ok_or_else(|| tera::Error::msg("Value must be a string"))?;
|
||||
|
||||
let format = args
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("%Y-%m-%d");
|
||||
|
||||
match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
Ok(date) => Ok(Value::String(date.format(format).to_string())),
|
||||
Err(_) => {
|
||||
// If parsing fails, return the original value
|
||||
Ok(value.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter for JSON formatting
|
||||
///
|
||||
/// Usage: `{{ data | json }}`
|
||||
pub struct JsonFilter;
|
||||
|
||||
impl Filter for JsonFilter {
|
||||
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
|
||||
serde_json::to_string_pretty(value)
|
||||
.map(Value::String)
|
||||
.map_err(|e| tera::Error::msg(format!("JSON error: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter for compact JSON formatting
|
||||
///
|
||||
/// Usage: `{{ data | json_compact }}`
|
||||
pub struct JsonCompactFilter;
|
||||
|
||||
impl Filter for JsonCompactFilter {
|
||||
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
|
||||
serde_json::to_string(value)
|
||||
.map(Value::String)
|
||||
.map_err(|e| tera::Error::msg(format!("JSON error: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter for string uppercasing
|
||||
///
|
||||
/// Usage: `{{ text | upper }}`
|
||||
pub struct UpperFilter;
|
||||
|
||||
impl Filter for UpperFilter {
|
||||
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
|
||||
value
|
||||
.as_str()
|
||||
.map(|s| Value::String(s.to_uppercase()))
|
||||
.ok_or_else(|| tera::Error::msg("Value must be a string"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter for string lowercasing
|
||||
///
|
||||
/// Usage: `{{ text | lower }}`
|
||||
pub struct LowerFilter;
|
||||
|
||||
impl Filter for LowerFilter {
|
||||
fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
|
||||
value
|
||||
.as_str()
|
||||
.map(|s| Value::String(s.to_lowercase()))
|
||||
.ok_or_else(|| tera::Error::msg("Value must be a string"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all custom filters with a Tera instance
|
||||
pub fn register_filters(tera: &mut tera::Tera) {
|
||||
tera.register_filter("date", DateFormatFilter);
|
||||
tera.register_filter("json", JsonFilter);
|
||||
tera.register_filter("json_compact", JsonCompactFilter);
|
||||
tera.register_filter("upper", UpperFilter);
|
||||
tera.register_filter("lower", LowerFilter);
|
||||
}
|
||||
129
crates/typedialog-core/src/templates/mod.rs
Normal file
129
crates/typedialog-core/src/templates/mod.rs
Normal file
@ -0,0 +1,129 @@
|
||||
//! Template engine support using Tera
|
||||
|
||||
mod context;
|
||||
mod filters;
|
||||
|
||||
pub use context::TemplateContextBuilder;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
use tera::Tera;
|
||||
|
||||
/// Template engine wrapper around Tera
|
||||
pub struct TemplateEngine {
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
/// Create a new template engine
|
||||
///
|
||||
/// If `templates_path` is provided, loads templates from that directory.
|
||||
/// Otherwise creates an engine for inline templates only.
|
||||
pub fn new(templates_path: Option<&Path>) -> Result<Self> {
|
||||
let mut tera = if let Some(path) = templates_path {
|
||||
let pattern = path.join("**/*.tera");
|
||||
let pattern_str = pattern.to_str().unwrap_or("templates/**/*.tera");
|
||||
|
||||
Tera::new(pattern_str).map_err(|e| {
|
||||
Error::template_failed(format!("Failed to initialize Tera: {}", e))
|
||||
})?
|
||||
} else {
|
||||
Tera::default()
|
||||
};
|
||||
|
||||
// Register custom filters
|
||||
filters::register_filters(&mut tera);
|
||||
|
||||
Ok(Self { tera })
|
||||
}
|
||||
|
||||
/// Add a template string for inline template rendering
|
||||
pub fn add_template(&mut self, name: &str, content: &str) -> Result<()> {
|
||||
self.tera.add_raw_template(name, content).map_err(|e| {
|
||||
Error::template_failed(format!("Failed to add template '{}': {}", name, e))
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a template by name with context
|
||||
pub fn render(&self, template_name: &str, context: &tera::Context) -> Result<String> {
|
||||
self.tera.render(template_name, context).map_err(|e| {
|
||||
Error::template_failed(format!("Failed to render template '{}': {}", template_name, e))
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a template string directly
|
||||
pub fn render_str(&self, template: &str, context: &tera::Context) -> Result<String> {
|
||||
Tera::one_off(template, context, false)
|
||||
.map_err(|e| Error::template_failed(format!("Failed to render template: {}", e)))
|
||||
}
|
||||
|
||||
/// Check if a template exists
|
||||
pub fn has_template(&self, name: &str) -> bool {
|
||||
self.tera.get_template(name).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_template_engine_new() {
|
||||
let engine = TemplateEngine::new(None);
|
||||
assert!(engine.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_engine_render_str() {
|
||||
let engine = TemplateEngine::new(None).unwrap();
|
||||
let context = TemplateContextBuilder::new()
|
||||
.with_data("name", &"Alice")
|
||||
.build();
|
||||
|
||||
let result = engine.render_str("Hello {{ name }}!", &context);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "Hello Alice!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_engine_add_and_render() {
|
||||
let mut engine = TemplateEngine::new(None).unwrap();
|
||||
let result = engine.add_template("greeting", "Hello {{ name }}!");
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(engine.has_template("greeting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_with_results() {
|
||||
let engine = TemplateEngine::new(None).unwrap();
|
||||
|
||||
let mut results = HashMap::new();
|
||||
results.insert("username".to_string(), json!("bob"));
|
||||
results.insert("email".to_string(), json!("bob@example.com"));
|
||||
|
||||
let context = TemplateContextBuilder::new()
|
||||
.with_results(&results)
|
||||
.build();
|
||||
|
||||
let template = "User: {{ username }}, Email: {{ email }}";
|
||||
let result = engine.render_str(template, &context);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "User: bob, Email: bob@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_with_filters() {
|
||||
let engine = TemplateEngine::new(None).unwrap();
|
||||
let context = TemplateContextBuilder::new()
|
||||
.with_data("text", &"hello")
|
||||
.build();
|
||||
|
||||
let result = engine.render_str("{{ text | upper }}", &context);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "HELLO");
|
||||
}
|
||||
}
|
||||
23
crates/typedialog-tui/Cargo.toml
Normal file
23
crates/typedialog-tui/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "typedialog-tui"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
description = "TypeDialog TUI tool for interactive forms using ratatui"
|
||||
|
||||
[[bin]]
|
||||
name = "typedialog-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
typedialog-core = { path = "../typedialog-core", features = ["tui", "i18n"] }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
93
crates/typedialog-tui/src/main.rs
Normal file
93
crates/typedialog-tui/src/main.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! typedialog-tui - Terminal UI tool for interactive forms
|
||||
//!
|
||||
//! A terminal UI (TUI) tool for creating interactive forms with enhanced visual presentation.
|
||||
//! Uses ratatui for advanced terminal rendering capabilities.
|
||||
|
||||
use clap::Parser;
|
||||
use typedialog_core::{form_parser, Error, Result};
|
||||
use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "typedialog-tui",
|
||||
version,
|
||||
about = "Terminal UI tool for interactive forms",
|
||||
long_about = cli_common::TUI_MAIN_LONG_ABOUT
|
||||
)]
|
||||
struct Args {
|
||||
/// Path to TOML form configuration file
|
||||
config: PathBuf,
|
||||
|
||||
/// Output format: json, yaml, toml, or text
|
||||
#[arg(short, long, default_value = "text", help = cli_common::FORMAT_FLAG_HELP)]
|
||||
format: String,
|
||||
|
||||
/// Output file (if not specified, writes to stdout)
|
||||
#[arg(short, long, help = cli_common::OUT_FLAG_HELP)]
|
||||
out: Option<PathBuf>,
|
||||
|
||||
/// Locale override for form localization
|
||||
#[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)]
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
execute_form(args.config, &args.format, &args.out, &args.locale).await
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// Load I18nBundle if needed
|
||||
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config.clone());
|
||||
let form_locale = form.locale.as_deref();
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
let loader = LocaleLoader::new(config.locales_path);
|
||||
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut backend = BackendFactory::create(BackendType::Tui)?;
|
||||
let results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).await?
|
||||
} else {
|
||||
form_parser::execute_with_backend(form, backend.as_mut()).await?
|
||||
};
|
||||
|
||||
print_results(&results, format, output_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_results(
|
||||
results: &HashMap<String, serde_json::Value>,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let output = helpers::format_results(results, format)?;
|
||||
|
||||
if let Some(path) = output_file {
|
||||
fs::write(path, &output).map_err(Error::io)?;
|
||||
} else {
|
||||
println!("{}", output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
23
crates/typedialog-web/Cargo.toml
Normal file
23
crates/typedialog-web/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "typedialog-web"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
description = "TypeDialog Web server for interactive forms using axum"
|
||||
|
||||
[[bin]]
|
||||
name = "typedialog-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
typedialog-core = { path = "../typedialog-core", features = ["web", "i18n"] }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
74
crates/typedialog-web/src/main.rs
Normal file
74
crates/typedialog-web/src/main.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! typedialog-web - Web server for interactive forms
|
||||
//!
|
||||
//! A web server tool for creating interactive forms accessible via HTTP.
|
||||
//! Uses axum web framework with HTMX for dynamic form interactions.
|
||||
|
||||
use clap::Parser;
|
||||
use typedialog_core::{form_parser, Error, Result};
|
||||
use typedialog_core::backends::{BackendFactory, BackendType};
|
||||
use typedialog_core::cli_common;
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "typedialog-web",
|
||||
version,
|
||||
about = "Web server for interactive forms",
|
||||
long_about = cli_common::WEB_MAIN_LONG_ABOUT
|
||||
)]
|
||||
struct Args {
|
||||
/// Path to TOML form configuration file
|
||||
config: PathBuf,
|
||||
|
||||
/// Port to listen on (can also be set via TYPEDIALOG_PORT env var)
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
port: u16,
|
||||
|
||||
/// Locale override for form localization
|
||||
#[arg(short, long, help = cli_common::LOCALE_FLAG_HELP)]
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
execute_form(args.config, args.port, &args.locale).await
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, port: u16, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// Load I18nBundle if needed
|
||||
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config.clone());
|
||||
let form_locale = form.locale.as_deref();
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
let loader = LocaleLoader::new(config.locales_path);
|
||||
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut backend = BackendFactory::create(BackendType::Web { port })?;
|
||||
|
||||
println!("Starting typedialog web server for form: {}", form.name);
|
||||
println!("Listening on http://localhost:{}", port);
|
||||
|
||||
let _results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).await?
|
||||
} else {
|
||||
form_parser::execute_with_backend(form, backend.as_mut()).await?
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
24
crates/typedialog/Cargo.toml
Normal file
24
crates/typedialog/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "typedialog"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
description = "TypeDialog CLI tool for interactive forms and prompts"
|
||||
|
||||
[[bin]]
|
||||
name = "typedialog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
typedialog-core = { path = "../typedialog-core", features = ["cli", "i18n"] }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
532
crates/typedialog/src/main.rs
Normal file
532
crates/typedialog/src/main.rs
Normal file
@ -0,0 +1,532 @@
|
||||
//! typedialog - Interactive forms and prompts CLI tool
|
||||
//!
|
||||
//! A powerful CLI tool for creating interactive forms and prompts using multiple backends.
|
||||
//! Works with piped input for batch processing and scripts.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use typedialog_core::{prompts, form_parser, Error, Result};
|
||||
use typedialog_core::backends::BackendFactory;
|
||||
use typedialog_core::helpers;
|
||||
use typedialog_core::nickel::{NickelCli, MetadataParser, TomlGenerator, TemplateEngine};
|
||||
use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver};
|
||||
use typedialog_core::config::TypeDialogConfig;
|
||||
use typedialog_core::cli_common;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use serde_json::json;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "typedialog",
|
||||
version,
|
||||
about = "Interactive forms and prompts CLI tool with multiple backend support",
|
||||
long_about = cli_common::CLI_MAIN_LONG_ABOUT
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
|
||||
/// Output format: json, yaml, toml, or text
|
||||
#[arg(global = true, short, long, default_value = "text", help = cli_common::FORMAT_FLAG_HELP)]
|
||||
format: String,
|
||||
|
||||
/// Output file (if not specified, writes to stdout)
|
||||
#[arg(global = true, short, long, help = cli_common::OUT_FLAG_HELP)]
|
||||
out: Option<PathBuf>,
|
||||
|
||||
/// Locale override for form localization
|
||||
#[arg(global = true, short, long, help = cli_common::LOCALE_FLAG_HELP)]
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Prompt for text input
|
||||
Text {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Default value
|
||||
#[arg(short, long)]
|
||||
default: Option<String>,
|
||||
|
||||
/// Placeholder text
|
||||
#[arg(short, long)]
|
||||
placeholder: Option<String>,
|
||||
},
|
||||
|
||||
/// Prompt for confirmation (yes/no)
|
||||
Confirm {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Default value (true/false)
|
||||
#[arg(short, long)]
|
||||
default: Option<bool>,
|
||||
},
|
||||
|
||||
/// Select a single option from a list
|
||||
Select {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Options to choose from
|
||||
options: Vec<String>,
|
||||
|
||||
/// Page size (number of options per page)
|
||||
#[arg(short, long)]
|
||||
page_size: Option<usize>,
|
||||
|
||||
/// Enable vim mode navigation
|
||||
#[arg(long)]
|
||||
vim_mode: bool,
|
||||
},
|
||||
|
||||
/// Select multiple options from a list
|
||||
#[command(name = "multi-select")]
|
||||
MultiSelect {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Options to choose from
|
||||
options: Vec<String>,
|
||||
|
||||
/// Page size (number of options per page)
|
||||
#[arg(short, long)]
|
||||
page_size: Option<usize>,
|
||||
|
||||
/// Enable vim mode navigation
|
||||
#[arg(long)]
|
||||
vim_mode: bool,
|
||||
},
|
||||
|
||||
/// Prompt for password input (masked)
|
||||
Password {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Allow showing/hiding password with Ctrl+R
|
||||
#[arg(long)]
|
||||
with_toggle: bool,
|
||||
},
|
||||
|
||||
/// Prompt for custom typed input
|
||||
Custom {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Type name (i32, f64, ipv4, ipv6, uuid, etc.)
|
||||
#[arg(short, long)]
|
||||
type_name: String,
|
||||
|
||||
/// Default value
|
||||
#[arg(short, long)]
|
||||
default: Option<String>,
|
||||
},
|
||||
|
||||
/// Prompt for text using an external editor
|
||||
Editor {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// File extension for the temp file
|
||||
#[arg(short, long)]
|
||||
file_extension: Option<String>,
|
||||
|
||||
/// Default content (text or path to file to load)
|
||||
#[arg(short, long)]
|
||||
default: Option<String>,
|
||||
},
|
||||
|
||||
/// Prompt for date selection using a calendar
|
||||
Date {
|
||||
/// Prompt message
|
||||
prompt: String,
|
||||
|
||||
/// Default date (YYYY-MM-DD format)
|
||||
#[arg(short, long)]
|
||||
default: Option<String>,
|
||||
|
||||
/// Minimum date (YYYY-MM-DD format)
|
||||
#[arg(long)]
|
||||
min_date: Option<String>,
|
||||
|
||||
/// Maximum date (YYYY-MM-DD format)
|
||||
#[arg(long)]
|
||||
max_date: Option<String>,
|
||||
|
||||
/// Week start day (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||
#[arg(long, default_value = "Mon")]
|
||||
week_start: String,
|
||||
},
|
||||
|
||||
/// Execute interactive form from TOML configuration
|
||||
Form {
|
||||
/// Path to TOML form configuration file
|
||||
config: PathBuf,
|
||||
|
||||
/// Optional path to Nickel template (.ncl.j2) for direct Nickel generation
|
||||
#[arg(value_name = "TEMPLATE")]
|
||||
template: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Convert Nickel schema to TOML form
|
||||
#[command(name = "nickel-to-form")]
|
||||
NickelToForm {
|
||||
/// Path to Nickel schema file (.ncl)
|
||||
schema: PathBuf,
|
||||
|
||||
/// Optional path to current data file (.ncl or .json) for defaults
|
||||
#[arg(value_name = "CURRENT_DATA")]
|
||||
current_data: Option<PathBuf>,
|
||||
|
||||
/// Flatten nested records into flat field names
|
||||
#[arg(long)]
|
||||
flatten: bool,
|
||||
|
||||
/// Use semantic grouping for form organization
|
||||
#[arg(long)]
|
||||
groups: bool,
|
||||
},
|
||||
|
||||
/// Convert form results to Nickel output
|
||||
#[command(name = "form-to-nickel")]
|
||||
FormToNickel {
|
||||
/// Path to TOML form definition
|
||||
form: PathBuf,
|
||||
|
||||
/// Path to results JSON file OR Nickel template (.ncl.j2)
|
||||
/// - If .json: Read pre-computed results (3-step workflow)
|
||||
/// - If .ncl.j2: Execute form + render template (2-step workflow)
|
||||
input: PathBuf,
|
||||
|
||||
/// Validate output with nickel typecheck
|
||||
#[arg(long)]
|
||||
validate: bool,
|
||||
},
|
||||
|
||||
/// Render Nickel template with form results
|
||||
#[command(name = "nickel-template")]
|
||||
NickelTemplate {
|
||||
/// Path to Nickel template file (.ncl.j2)
|
||||
template: PathBuf,
|
||||
|
||||
/// Path to results JSON file
|
||||
results: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Text {
|
||||
prompt,
|
||||
default,
|
||||
placeholder,
|
||||
} => {
|
||||
let result = prompts::text(&prompt, default.as_deref(), placeholder.as_deref())?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Confirm { prompt, default } => {
|
||||
let result = prompts::confirm(&prompt, default, None)?;
|
||||
print_result("value", &result.to_string(), &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Select {
|
||||
prompt,
|
||||
options,
|
||||
page_size,
|
||||
vim_mode,
|
||||
} => {
|
||||
let result = prompts::select(&prompt, options, page_size, vim_mode)?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::MultiSelect {
|
||||
prompt,
|
||||
options,
|
||||
page_size,
|
||||
vim_mode,
|
||||
} => {
|
||||
let results = prompts::multi_select(&prompt, options, page_size, vim_mode)?;
|
||||
let output = json!(results);
|
||||
print_result("values", &output.to_string(), &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Password {
|
||||
prompt,
|
||||
with_toggle,
|
||||
} => {
|
||||
let result = prompts::password(&prompt, with_toggle)?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Custom {
|
||||
prompt,
|
||||
type_name,
|
||||
default,
|
||||
} => {
|
||||
let result = prompts::custom(&prompt, &type_name, default.as_deref())?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Editor {
|
||||
prompt,
|
||||
file_extension,
|
||||
default,
|
||||
} => {
|
||||
let result = prompts::editor(&prompt, file_extension.as_deref(), default.as_deref())?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Date {
|
||||
prompt,
|
||||
default,
|
||||
min_date,
|
||||
max_date,
|
||||
week_start,
|
||||
} => {
|
||||
let result = prompts::date(&prompt, default.as_deref(), min_date.as_deref(), max_date.as_deref(), &week_start)?;
|
||||
print_result("value", &result, &cli.format, &cli.out)?;
|
||||
}
|
||||
|
||||
Commands::Form { config, template } => {
|
||||
execute_form(config, template, &cli.format, &cli.out, &cli.locale).await?;
|
||||
}
|
||||
|
||||
Commands::NickelToForm {
|
||||
schema,
|
||||
current_data,
|
||||
flatten,
|
||||
groups,
|
||||
} => {
|
||||
nickel_to_form_cmd(schema, current_data, &cli.out, flatten, groups)?;
|
||||
}
|
||||
|
||||
Commands::FormToNickel {
|
||||
form,
|
||||
input,
|
||||
validate,
|
||||
} => {
|
||||
form_to_nickel_cmd(form, input, &cli.out, validate)?;
|
||||
}
|
||||
|
||||
Commands::NickelTemplate {
|
||||
template,
|
||||
results,
|
||||
} => {
|
||||
nickel_template_cmd(template, results, &cli.out)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_form(config: PathBuf, template: Option<PathBuf>, format: &str, output_file: &Option<PathBuf>, cli_locale: &Option<String>) -> Result<()> {
|
||||
let toml_content = fs::read_to_string(&config)
|
||||
.map_err(Error::io)?;
|
||||
|
||||
let form = form_parser::parse_toml(&toml_content)?;
|
||||
|
||||
// Load I18nBundle if needed
|
||||
let i18n_bundle = if form.locale.is_some() || cli_locale.is_some() {
|
||||
// Resolve locale: CLI flag > form locale > env var > default
|
||||
let config = TypeDialogConfig::default();
|
||||
let resolver = LocaleResolver::new(config.clone());
|
||||
let form_locale = form.locale.as_deref();
|
||||
|
||||
// resolve() already returns a LanguageIdentifier
|
||||
let locale = resolver.resolve(cli_locale.as_deref(), form_locale);
|
||||
let fallback_locale: LanguageIdentifier = "en-US".parse()
|
||||
.map_err(|_| Error::validation_failed("Invalid fallback locale".to_string()))?;
|
||||
|
||||
// Load translations
|
||||
let loader = LocaleLoader::new(config.locales_path);
|
||||
Some(I18nBundle::new(locale, fallback_locale, &loader)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Auto-detect backend from TYPEDIALOG_BACKEND env var (tui/web/cli, default cli)
|
||||
let backend_type = BackendFactory::auto_detect();
|
||||
let mut backend = BackendFactory::create(backend_type)?;
|
||||
|
||||
// Execute form with i18n support
|
||||
let results = if let Some(ref bundle) = i18n_bundle {
|
||||
form_parser::execute_with_backend_i18n(form, backend.as_mut(), Some(bundle)).await?
|
||||
} else {
|
||||
form_parser::execute_with_backend(form, backend.as_mut()).await?
|
||||
};
|
||||
|
||||
// If template provided, generate Nickel output directly
|
||||
if let Some(template_path) = template {
|
||||
// Load and render template with form results
|
||||
let mut engine = TemplateEngine::new();
|
||||
let nickel_output = engine.render_file(template_path.as_path(), &results)?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output_file {
|
||||
fs::write(path, &nickel_output).map_err(Error::io)?;
|
||||
} else {
|
||||
println!("{}", nickel_output);
|
||||
}
|
||||
} else {
|
||||
// No template: return results in requested format (json, yaml, text)
|
||||
print_results(&results, format, output_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_result(
|
||||
key: &str,
|
||||
value: &str,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let output = match format {
|
||||
"json" => {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(key, value);
|
||||
serde_json::to_string_pretty(&map).unwrap_or_default()
|
||||
}
|
||||
"yaml" => {
|
||||
format!("{}: {}", key, value)
|
||||
}
|
||||
"toml" => {
|
||||
format!("{} = \"{}\"", key, value.escape_default())
|
||||
}
|
||||
_ => value.to_string(),
|
||||
};
|
||||
|
||||
if let Some(path) = output_file {
|
||||
fs::write(path, &output).map_err(Error::io)?;
|
||||
} else {
|
||||
println!("{}", output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_results(
|
||||
results: &HashMap<String, serde_json::Value>,
|
||||
format: &str,
|
||||
output_file: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let output = helpers::format_results(results, format)?;
|
||||
|
||||
if let Some(path) = output_file {
|
||||
fs::write(path, &output).map_err(Error::io)?;
|
||||
} else {
|
||||
println!("{}", output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nickel_to_form_cmd(
|
||||
schema: PathBuf,
|
||||
_current_data: Option<PathBuf>,
|
||||
output: &Option<PathBuf>,
|
||||
flatten: bool,
|
||||
groups: bool,
|
||||
) -> Result<()> {
|
||||
// Verify nickel CLI is available
|
||||
NickelCli::verify()?;
|
||||
|
||||
// Extract metadata from schema
|
||||
let metadata = NickelCli::query(schema.as_path(), Some("inputs"))?;
|
||||
|
||||
// Parse into intermediate representation
|
||||
let schema_ir = MetadataParser::parse(metadata)?;
|
||||
|
||||
// Generate TOML form
|
||||
let form_def = TomlGenerator::generate(&schema_ir, flatten, groups)?;
|
||||
|
||||
// Serialize to TOML
|
||||
let toml_output = ::toml::to_string_pretty(&form_def)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
fs::write(path, &toml_output).map_err(Error::io)?;
|
||||
println!("Form written to {}", path.display());
|
||||
} else {
|
||||
println!("{}", toml_output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn form_to_nickel_cmd(
|
||||
form: PathBuf,
|
||||
input: PathBuf,
|
||||
output: &Option<PathBuf>,
|
||||
_validate: bool,
|
||||
) -> Result<()> {
|
||||
let form_content = fs::read_to_string(&form).map_err(Error::io)?;
|
||||
let _form_def = form_parser::parse_toml(&form_content)?;
|
||||
|
||||
// Determine input type based on extension
|
||||
let results: HashMap<String, serde_json::Value> = if input.extension().and_then(|s| s.to_str()) == Some("ncl.j2") {
|
||||
// Template: would require executing form and rendering template
|
||||
// For now, return error as this requires interactive execution
|
||||
return Err(Error::validation_failed(
|
||||
"Template-based form-to-nickel requires interactive execution. Use .json input instead."
|
||||
));
|
||||
} else if input.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
// Load pre-computed results from JSON
|
||||
let json_content = fs::read_to_string(&input).map_err(Error::io)?;
|
||||
serde_json::from_str(&json_content).map_err(|e| Error::validation_failed(e.to_string()))?
|
||||
} else {
|
||||
return Err(Error::validation_failed(
|
||||
"Input file must be .json or .ncl.j2"
|
||||
));
|
||||
};
|
||||
|
||||
// For now, provide a placeholder message as full Nickel serialization requires schema
|
||||
let nickel_output = format!("# Form results (JSON format for now)\n{}",
|
||||
serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?);
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
fs::write(path, &nickel_output).map_err(Error::io)?;
|
||||
println!("Nickel output written to {}", path.display());
|
||||
} else {
|
||||
println!("{}", nickel_output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nickel_template_cmd(
|
||||
template: PathBuf,
|
||||
results: PathBuf,
|
||||
output: &Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
// Load results JSON file
|
||||
let json_content = fs::read_to_string(&results).map_err(Error::io)?;
|
||||
let values: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(&json_content)
|
||||
.map_err(|e| Error::validation_failed(e.to_string()))?;
|
||||
|
||||
// Load and render template
|
||||
let mut engine = TemplateEngine::new();
|
||||
let nickel_output = engine.render_file(template.as_path(), &values)?;
|
||||
|
||||
// Write output
|
||||
if let Some(path) = output {
|
||||
fs::write(path, &nickel_output).map_err(Error::io)?;
|
||||
println!("Template rendered to {}", path.display());
|
||||
} else {
|
||||
println!("{}", nickel_output);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user