Replace all TOML form definitions in examples/ and config/ with type-checked Nickel equivalents. Update cli_loader to prefer .ncl (via nickel export) over .toml in config search order. TOML support retained as fallback — no breaking change. - El loader usa nickel export --format json + serde_json como puente — evita reimplementar un parser Nickel en Rust y aprovecha el binario ya existente. - El orden de búsqueda .ncl > .toml permite migración incremental: cualquier config vieja sigue funcionando sin tocarla. - Los contratos Nickel (| default, | String) en los configs sustituyen la validación que antes era implícita en el parsing TOML — el error llega antes (en nickel export) con mensajes más descriptivos.
9.4 KiB
Architecture
Technical positioning and internal architecture of TypeDialog.
Positioning
TypeDialog is not a terminal prompt library — it is a form orchestration layer that sits two abstraction levels above tools like Inquire, Ratatui, or Axum.
The comparison point is relevant: Inquire (inquire = "0.9") is one of TypeDialog's dependencies, consumed as the render engine for the CLI backend. A user of TypeDialog never calls Text::new("Name?").prompt() directly — that call happens inside InquireBackend::execute_field_sync, invisible to the form author.
Abstraction Stack
┌─────────────────────────────────────────────────────┐
│ typedialog │
│ Declarative forms (Nickel / TOML) + i18n + AI │
│ load_form → BackendFactory → dispatch by feature │
├──────────────┬──────────────┬──────────────────────-┤
│ CLI backend │ TUI backend │ Web backend │
│ (Inquire) │ (Ratatui) │ (Axum) │
└──────────────┴──────────────┴───────────────────────┘
│
Nushell plugin (nu-protocol)
What TypeDialog Is Not
| What the primitive does | TypeDialog |
|---|---|
| Library for use in your CLI app | Is the app — or the library others embed |
| Terminal input tool | Multi-surface entry point (CLI, TUI, Web, AI) |
| Comparable to Rhai or Nushell | Integrates Nushell via nu-plugin/nu-protocol as an output channel |
Differentiators
Unified schema — a single Nickel (.ncl) or TOML file describes the same form for CLI, TUI, and HTTP. No per-backend code. See ADR-001.
Nushell as integration runtime — outputs flow as structured data into Nu pipelines via the Nushell plugin protocol (nu-plugin = "0.110.0"), not as text.
AI backend (typedialog-ai + typedialog-agent) — forms can be generated or processed by agents backed by SurrealDB (kv-mem), Tantivy full-text search, and HNSW vector search (instant-distance).
Native i18n — Fluent bundles with system locale detection, no external tooling required.
Form Loading
load_form — Unified Entry Point
All binaries and roundtrip paths call form_parser::load_form(path). Extension determines the loader:
pub fn load_form(path: impl AsRef<Path>) -> Result<FormDefinition> {
match path.as_ref().extension().and_then(|e| e.to_str()) {
Some("ncl") => load_from_ncl(path),
_ => load_from_file(path), // TOML path, unchanged
}
}
.ncl forms are loaded via nickel export --format json as a subprocess. If the export fails for any reason — contract violation, syntax error, missing import — the process aborts before any interaction begins. TOML forms are deserialized directly via serde.
Fragment lazy-loading (includes: in TOML groups) is replaced by Nickel native imports. build_element_list is now a pure in-memory operation with no I/O. The functions expand_includes and load_fragment_form remain available in fragments.rs as rescue code.
when_false — Conditional Field Output
Fields with a when: condition that evaluates to false are skipped by default. when_false = "default" instructs the executor to inject the field's default value into results even when the condition is false:
[fields.tls_cert_path]
when = "tls_enabled == true"
when_false = "default"
default = "/etc/ssl/cert.pem"
This ensures Nickel roundtrip writers always have a value for every schema field regardless of which branches the user navigated.
BackendFactory
The FormBackend Trait
All backends implement a single async trait (backends/mod.rs:27):
#[async_trait]
pub trait FormBackend: Send + Sync {
async fn initialize(&mut self) -> Result<()>;
async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>;
async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value>;
// Default impl: falls back to field-by-field via execute_field
// TUI and Web override this for complete-form rendering
async fn execute_form_complete(...) -> Result<HashMap<String, Value>>;
async fn shutdown(&mut self) -> Result<()>;
fn as_any(&self) -> &dyn std::any::Any;
fn is_available() -> bool where Self: Sized;
fn name(&self) -> &str;
}
execute_form_complete has a default implementation that delegates to execute_field sequentially. Backends that need full-form rendering (TUI, Web) override it.
Factory Dispatch
BackendFactory::create returns Box<dyn FormBackend>. The selection is a compile-time #[cfg(feature)] gate combined with runtime match (backends/mod.rs:109):
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(/* clear message */) }
}
#[cfg(feature = "tui")]
BackendType::Tui => Ok(Box::new(tui::RatatuiBackend::new())),
#[cfg(feature = "web")]
BackendType::Web { port } => Ok(Box::new(web::WebBackend::new(port))),
}
}
The backend code for a disabled feature is not compiled into the binary. The trait object provides runtime polymorphism; the feature gate provides compile-time dead-code elimination.
Auto-Detection
BackendFactory::auto_detect follows a fallback chain (backends/mod.rs:132):
TYPEDIALOG_BACKEND=tui → TUI (if feature active)
TYPEDIALOG_BACKEND=web → Web (port from TYPEDIALOG_PORT, default 9000)
default → Cli
RenderContext
Passed to every render and field execution call:
pub struct RenderContext {
pub results: HashMap<String, Value>, // accumulated field values
pub locale: Option<String>, // locale override
}
results carries the accumulated answers from previous fields. InquireBackend::execute_field_sync
receives this as previous_results and uses it for options_from filtering in Select fields.
Form Execution
Three-Phase Execution
execute_with_backend_two_phase_with_defaults (form_parser/executor.rs) is the primary execution path:
Phase 1 — Selector fields first
Fields that control when: conditionals are identified and executed before Phase 2.
This ensures conditional branches are known before iterating the element list.
let selector_field_names = identify_selector_fields(&form);
// execute only selectors → populate results
Phase 2 — Build element list (pure)
With Phase 1 results known, the element list is built from the fully-composed in-memory form.
No I/O occurs here — fragments were resolved by load_form before execution began.
let element_list = build_element_list(&form);
Phase 3 — Execute remaining fields
Iterates the element list, evaluates when: conditions per-element, applies when_false defaults
for skipped fields, and dispatches to the backend. Two sub-modes:
DisplayMode::Complete— passes all items and fields toexecute_form_completeat once (used by TUI and Web for reactive rendering)- Field-by-field — sequential execution with condition evaluation per step (used by CLI)
Full Execution Flow
typedialog binary
│
├── form_parser::load_form(path) → FormDefinition (.ncl via nickel export | .toml via serde)
├── BackendFactory::create(BackendType::Cli) → Box<dyn FormBackend>
├── execute_with_backend_two_phase_with_defaults(form, backend, i18n, base_dir, defaults)
│ │
│ ├── Phase 1: selector fields → backend.execute_field()
│ ├── Phase 2: build_element_list() — pure, no I/O
│ └── Phase 3: iterate → when/when_false → render_display_item() / execute_field()
│
└── HashMap<String, Value> → JSON / YAML / TOML output
CLI Field Execution
The InquireBackend implements field execution as a synchronous function (backends/cli.rs):
pub(crate) fn execute_field_sync(
&self,
field: &FieldDefinition,
previous_results: &HashMap<String, Value>,
) -> Result<Value>
The FormBackend::execute_field trait impl wraps it:
async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result<Value> {
self.execute_field_sync(field, &context.results)
}
filter_options_from lives in backends/cli.rs as pub(crate) and is called from within
execute_field_sync for Select fields. It filters the declared options to only those present
in a referenced prior field's value — enabling dependent selects.
Legacy Sync Path
execute_with_base_dir, execute, and load_and_execute_from_file are a pre-BackendFactory
sync path gated on #[cfg(feature = "cli")]. They call InquireBackend::execute_field_sync
directly and bypass the async executor. Available for backward compatibility; the canonical path
for new code is execute_with_backend_two_phase_with_defaults.
Known Technical Debt
No unresolved items.