refactor(core): consolidate CLI field execution into single dispatch point
Some checks failed
CI / Lint (bash) (push) Has been cancelled
CI / Lint (markdown) (push) Has been cancelled
CI / Lint (nickel) (push) Has been cancelled
CI / Lint (nushell) (push) Has been cancelled
CI / Lint (rust) (push) Has been cancelled
CI / Code Coverage (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
CI / Benchmark (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / License Compliance (push) Has been cancelled

Three independent copies of `match field.field_type` existed across
  executor.rs, backends/cli.rs, and nickel/roundtrip.rs — each with
  different feature gaps (missing options_from, missing RepeatingGroup,
  duplicate filter_options_from).

  - Canonical implementation: InquireBackend::execute_field_sync (pub(crate))
  - Added previous_results: &HashMap<String, Value> parameter — enables
    options_from filtering for dependent Select fields, previously absent
    in the cli.rs copy
  - filter_options_from moved to backends/cli.rs (pub(crate)), removed from
    executor.rs and roundtrip.rs
  - Legacy sync path (execute_with_base_dir, execute, load_and_execute_from_file)
    gated on #[cfg(feature = "cli")] — correct: they call InquireBackend directly
  - Net: -346 lines, 0 behavior regressions, 36 tests passing

  fix(docs): repair five doctests broken against current API

  - FormDefinition struct fields renamed (title→name, display_items→elements):
    lib.rs, prelude.rs updated to use form_parser::parse_toml()
  - confirm() takes 3 args (prompt, default, formatter): lib.rs, prompt_api.rs
  - advanced.rs: replaced 8 nonexistent trait methods with 7 real FormBackend methods
  - cli_loader.rs: ignore→no_run with concrete inline struct

  docs(arch): add architecture.md with positioning, BackendFactory, execution model
This commit is contained in:
Jesús Pérez 2026-02-17 14:37:50 +00:00
parent 78e995177b
commit d7ebcf7c5e
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
10 changed files with 150 additions and 457 deletions

View File

@ -2,6 +2,48 @@
## [Unreleased]
### Refactored - Eliminate duplicated field execution logic
**Single source of truth for CLI field dispatch**
Three independent copies of the `match field.field_type` dispatch existed across
`form_parser/executor.rs`, `backends/cli.rs`, and `nickel/roundtrip.rs`. Each had
drifted in behavior: `executor.rs` lacked `RepeatingGroup`, `cli.rs` lacked
`options_from` filtering, `roundtrip.rs` had both but also its own `filter_options_from`.
- Consolidated into `InquireBackend::execute_field_sync` (`backends/cli.rs`) as the
single canonical implementation
- Added `previous_results: &HashMap<String, Value>` parameter to `execute_field_sync`
(now `pub(crate)`) — unlocks `options_from` filtering for dependent `Select` fields
- Moved `filter_options_from` to `backends/cli.rs` as `pub(crate)`, removed the two
duplicate copies from `executor.rs` and `nickel/roundtrip.rs`
- `FormBackend::execute_field` trait impl now passes `context.results` directly to
`execute_field_sync` instead of ignoring the accumulated results
- Legacy sync path (`execute_with_base_dir`, `execute`, `load_and_execute_from_file`)
gated on `#[cfg(feature = "cli")]` — semantically correct, previously they compiled
against non-CLI feature combinations without the Inquire dependency being present
- Net: 346 lines across 4 files, 0 behavior regressions
**Fixed - Doctest compilation failures**
Five doctests in `typedialog-core` failed to compile against the current API:
- `lib.rs` / `prelude.rs`: `FormDefinition { title, display_items }` → fields renamed
to `name` / `elements` in a prior refactor; replaced with `form_parser::parse_toml()`
- `lib.rs` / `prompt_api.rs`: `confirm(prompt, default)` → takes 3 args
(`prompt, default, formatter`); added missing `None`
- `advanced.rs`: example implemented `render_text`, `render_confirm`, etc. — methods
that no longer exist in `FormBackend`; replaced with the 7 real trait methods
- `config/cli_loader.rs`: `ignore` annotation with fictional `MyBackendConfig` type;
replaced with a concrete inline struct and changed to `no_run`
**Added - Architecture documentation**
- New `docs/architecture.md`: technical positioning (TypeDialog vs Inquire/Ratatui/Axum),
`BackendFactory` mechanics, three-phase execution model, `CLI Field Execution` section
documenting `execute_field_sync` + `filter_options_from` contract, legacy sync path,
and remaining known technical debt
### Added - Web Backend Browser Auto-Launch
**`--open` Flag for typedialog-web**

View File

@ -9,33 +9,36 @@
//! # Examples
//!
//! ```no_run
//! use typedialog_core::advanced::{FormBackend, RenderContext};
//! use typedialog_core::advanced::{FormBackend, RenderContext, DisplayItem};
//! use typedialog_core::error::Result;
//! use typedialog_core::form_parser::FieldDefinition;
//! use serde_json::Value;
//!
//! // Implement a custom backend
//! struct MyCustomBackend;
//!
//! #[async_trait::async_trait]
//! impl FormBackend for MyCustomBackend {
//! async fn render_text(
//! &mut self,
//! prompt: &str,
//! default: Option<&str>,
//! placeholder: Option<&str>,
//! _context: &RenderContext,
//! ) -> Result<String> {
//! // Custom implementation
//! Ok(format!("{}: custom", prompt))
//! async fn initialize(&mut self) -> Result<()> {
//! Ok(())
//! }
//!
//! // ... implement other required methods
//! # async fn render_confirm(&mut self, _: &str, _: Option<bool>, _: &RenderContext) -> Result<bool> { Ok(true) }
//! # async fn render_select(&mut self, _: &str, _: &[String], _: Option<usize>, _: Option<usize>, _: bool, _: &RenderContext) -> Result<String> { Ok("".into()) }
//! # async fn render_multiselect(&mut self, _: &str, _: &[String], _: &[String], _: Option<usize>, _: bool, _: &RenderContext) -> Result<Vec<String>> { Ok(vec![]) }
//! # async fn render_password(&mut self, _: &str, _: &RenderContext) -> Result<String> { Ok("".into()) }
//! # async fn render_custom(&mut self, _: &str, _: &str, _: &RenderContext) -> Result<String> { Ok("".into()) }
//! # async fn render_editor(&mut self, _: &str, _: Option<&str>, _: Option<&str>, _: &RenderContext) -> Result<String> { Ok("".into()) }
//! # async fn render_date(&mut self, _: &str, _: Option<chrono::NaiveDate>, _: Option<chrono::NaiveDate>, _: Option<chrono::NaiveDate>, _: chrono::Weekday, _: &RenderContext) -> Result<chrono::NaiveDate> { Ok(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()) }
//! async fn render_display_item(&self, _item: &DisplayItem, _ctx: &RenderContext) -> Result<()> {
//! Ok(())
//! }
//!
//! async fn execute_field(&self, field: &FieldDefinition, _ctx: &RenderContext) -> Result<Value> {
//! Ok(Value::String(format!("response for {}", field.name)))
//! }
//!
//! async fn shutdown(&mut self) -> Result<()> {
//! Ok(())
//! }
//!
//! fn as_any(&self) -> &dyn std::any::Any { self }
//!
//! fn is_available() -> bool { true }
//!
//! fn name(&self) -> &str { "my-custom-backend" }
//! }
//! ```

View File

@ -5,12 +5,38 @@
use super::{FormBackend, RenderContext};
use crate::error::Result;
use crate::form_parser::{DisplayItem, FieldDefinition, FieldType};
use crate::form_parser::{DisplayItem, FieldDefinition, FieldType, SelectOption};
use crate::prompts;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
pub(crate) fn filter_options_from(
field: &FieldDefinition,
previous_results: &HashMap<String, Value>,
) -> Vec<SelectOption> {
let Some(ref source_field) = field.options_from else {
return field.options.clone();
};
let Some(source_value) = previous_results.get(source_field) else {
return field.options.clone();
};
let selected_values: Vec<String> = match source_value {
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect(),
Value::String(s) => s.split(',').map(|item| item.trim().to_owned()).collect(),
_ => return field.options.clone(),
};
field
.options
.iter()
.filter(|opt| selected_values.contains(&opt.value))
.cloned()
.collect()
}
/// CLI Backend implementation using inquire
pub struct InquireBackend;
@ -20,8 +46,11 @@ impl InquireBackend {
InquireBackend
}
#[allow(clippy::only_used_in_recursion)]
fn execute_field_sync(&self, field: &FieldDefinition) -> Result<Value> {
pub(crate) fn execute_field_sync(
&self,
field: &FieldDefinition,
previous_results: &HashMap<String, Value>,
) -> Result<Value> {
let is_required = field.required.unwrap_or(false);
let required_marker = if is_required { " *" } else { " (optional)" };
@ -36,7 +65,7 @@ impl InquireBackend {
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
return self.execute_field_sync(field, previous_results);
}
Ok(serde_json::json!(result))
}
@ -63,7 +92,7 @@ impl InquireBackend {
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
return self.execute_field_sync(field, previous_results);
}
Ok(serde_json::json!(result))
}
@ -75,8 +104,14 @@ impl InquireBackend {
));
}
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let options = field
.options
let filtered = filter_options_from(field, previous_results);
if filtered.is_empty() {
return Err(crate::ErrorWrapper::form_parse_failed(format!(
"No options available for field '{}'. Check options_from reference.",
field.name
)));
}
let options = filtered
.iter()
.map(|opt| opt.as_string())
.collect::<Vec<_>>();
@ -110,7 +145,7 @@ impl InquireBackend {
if is_required && results.is_empty() {
eprintln!("⚠ This field is required. Please select at least one option.");
return self.execute_field_sync(field);
return self.execute_field_sync(field, previous_results);
}
Ok(serde_json::json!(results))
}
@ -125,7 +160,7 @@ impl InquireBackend {
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
return self.execute_field_sync(field, previous_results);
}
Ok(serde_json::json!(result))
}
@ -153,7 +188,7 @@ impl InquireBackend {
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return self.execute_field_sync(field);
return self.execute_field_sync(field, previous_results);
}
Ok(serde_json::json!(result))
}
@ -311,7 +346,7 @@ impl InquireBackend {
for element in &fragment_form.elements {
if let crate::form_parser::FormElement::Field(field_def) = element {
let value = backend.execute_field_sync(field_def)?;
let value = backend.execute_field_sync(field_def, &results)?;
results.insert(field_def.name.clone(), value);
}
}
@ -435,10 +470,9 @@ impl FormBackend for InquireBackend {
async fn execute_field(
&self,
field: &FieldDefinition,
_context: &RenderContext,
context: &RenderContext,
) -> Result<Value> {
// Wrap synchronous field execution in async context
self.execute_field_sync(field)
self.execute_field_sync(field, &context.results)
}
fn as_any(&self) -> &dyn std::any::Any {

View File

@ -21,15 +21,26 @@ use std::path::Path;
///
/// # Example
///
/// ```ignore
/// ```no_run
/// use typedialog_core::config::load_backend_config;
/// use serde::{Deserialize, Serialize};
/// use std::path::PathBuf;
///
/// let config = load_backend_config::<MyBackendConfig>(
/// "cli",
/// Some(PathBuf::from("custom.toml").as_path()),
/// MyBackendConfig::default()
/// )?;
/// #[derive(Serialize, Deserialize, Default)]
/// struct CliConfig {
/// timeout: u32,
/// verbose: bool,
/// }
///
/// # fn main() -> typedialog_core::error::Result<()> {
/// // Without explicit path: searches ~/.config/typedialog/cli/
/// let config = load_backend_config::<CliConfig>("cli", None, CliConfig::default())?;
///
/// // With explicit path
/// let path = PathBuf::from("custom.toml");
/// let config = load_backend_config::<CliConfig>("cli", Some(path.as_path()), CliConfig::default())?;
/// # Ok(())
/// # }
/// ```
pub fn load_backend_config<T>(
backend_name: &str,

View File

@ -3,16 +3,13 @@
//! Handles form execution with various backends and execution modes.
use crate::error::Result;
use crate::prompts;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use super::conditions::evaluate_condition;
use super::parser::load_elements_from_file;
use super::translation::{translate_display_item, translate_field_definition};
use super::types::{
DisplayItem, DisplayMode, FieldDefinition, FieldType, FormDefinition, FormElement,
};
use super::types::{DisplayItem, DisplayMode, FieldDefinition, FormDefinition, FormElement};
#[cfg(feature = "i18n")]
use crate::i18n::I18nBundle;
@ -81,6 +78,7 @@ pub fn render_display_item(item: &DisplayItem, results: &HashMap<String, serde_j
}
}
#[cfg(feature = "cli")]
pub fn execute_with_base_dir(
mut form: FormDefinition,
base_dir: &Path,
@ -141,7 +139,8 @@ pub fn execute_with_base_dir(
}
}
let value = execute_field(field, &results)?;
let value = crate::backends::cli::InquireBackend::new()
.execute_field_sync(field, &results)?;
results.insert(field.name.clone(), value.clone());
}
}
@ -151,11 +150,13 @@ pub fn execute_with_base_dir(
}
/// Execute a form and collect results (no path resolution - for backwards compatibility)
#[cfg(feature = "cli")]
pub fn execute(form: FormDefinition) -> Result<HashMap<String, serde_json::Value>> {
execute_with_base_dir(form, Path::new("."))
}
/// Load form from TOML file and execute with proper path resolution
#[cfg(feature = "cli")]
pub fn load_and_execute_from_file(
path: impl AsRef<Path>,
) -> Result<HashMap<String, serde_json::Value>> {
@ -174,200 +175,6 @@ pub fn load_and_execute_from_file(
execute_with_base_dir(form, base_dir)
}
/// Filter field options based on options_from reference
fn filter_options_from(
field: &FieldDefinition,
previous_results: &HashMap<String, serde_json::Value>,
) -> Vec<super::types::SelectOption> {
// If no options_from specified, return all options
let Some(ref source_field) = field.options_from else {
return field.options.clone();
};
// Get the source field's value
let Some(source_value) = previous_results.get(source_field) else {
// Source field not found, return all options
return field.options.clone();
};
// Extract selected values from source field (could be array or comma-separated string)
let selected_values: Vec<String> = match source_value {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
serde_json::Value::String(s) => s.split(',').map(|item| item.trim().to_string()).collect(),
_ => return field.options.clone(), // Unsupported type, return all
};
// Filter options to only include those in selected_values
field
.options
.iter()
.filter(|opt| selected_values.contains(&opt.value))
.cloned()
.collect()
}
/// Execute a single field
fn execute_field(
field: &FieldDefinition,
_previous_results: &HashMap<String, serde_json::Value>,
) -> Result<serde_json::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 execute_field(field, _previous_results); // Retry
}
Ok(serde_json::json!(result))
}
FieldType::Confirm => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let default_bool =
field
.default
.as_deref()
.and_then(|s| match s.to_lowercase().as_str() {
"true" | "yes" => Some(true),
"false" | "no" => Some(false),
_ => None,
});
let result = prompts::confirm(&prompt_with_marker, default_bool, None)?;
Ok(serde_json::json!(result))
}
FieldType::Password => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let with_toggle = field.placeholder.as_deref() == Some("toggle");
let result = prompts::password(&prompt_with_marker, with_toggle)?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return execute_field(field, _previous_results); // Retry
}
Ok(serde_json::json!(result))
}
FieldType::Select => {
if field.options.is_empty() {
return Err(crate::ErrorWrapper::form_parse_failed(
"Select field requires 'options'",
));
}
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
// Filter options based on options_from if specified
let filtered_options = filter_options_from(field, _previous_results);
if filtered_options.is_empty() {
return Err(crate::ErrorWrapper::form_parse_failed(format!(
"No options available for field '{}'. Check options_from reference.",
field.name
)));
}
let options = filtered_options
.iter()
.map(|opt| opt.as_string())
.collect::<Vec<_>>();
let result = prompts::select(
&prompt_with_marker,
options,
field.page_size,
field.vim_mode.unwrap_or(false),
)?;
Ok(serde_json::json!(result))
}
FieldType::MultiSelect => {
if field.options.is_empty() {
return Err(crate::ErrorWrapper::form_parse_failed(
"MultiSelect field requires 'options'",
));
}
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let options = field
.options
.iter()
.map(|opt| opt.as_string())
.collect::<Vec<_>>();
let results = prompts::multi_select(
&prompt_with_marker,
options,
field.page_size,
field.vim_mode.unwrap_or(false),
)?;
if is_required && results.is_empty() {
eprintln!("⚠ This field is required. Please select at least one option.");
return execute_field(field, _previous_results); // Retry
}
Ok(serde_json::json!(results))
}
FieldType::Editor => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let result = prompts::editor(
&prompt_with_marker,
field.file_extension.as_deref(),
field.prefix_text.as_deref(),
)?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return execute_field(field, _previous_results); // Retry
}
Ok(serde_json::json!(result))
}
FieldType::Date => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let week_start = field.week_start.as_deref().unwrap_or("Mon");
let result = prompts::date(
&prompt_with_marker,
field.default.as_deref(),
field.min_date.as_deref(),
field.max_date.as_deref(),
week_start,
)?;
Ok(serde_json::json!(result))
}
FieldType::Custom => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let type_name = field.custom_type.as_ref().ok_or_else(|| {
crate::ErrorWrapper::form_parse_failed("Custom field requires 'custom_type'")
})?;
let result = prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?;
if is_required && result.is_empty() {
eprintln!("⚠ This field is required. Please enter a value.");
return execute_field(field, _previous_results); // Retry
}
Ok(serde_json::json!(result))
}
FieldType::RepeatingGroup => {
// Temporary stub - will be implemented in FASE 4
Err(crate::ErrorWrapper::form_parse_failed(
"RepeatingGroup not yet implemented - use CLI backend (FASE 4)",
))
}
}
}
/// Build element list from form definition with lazy loading of fragments
fn build_element_list(
form: &FormDefinition,

View File

@ -25,15 +25,18 @@ pub use types::{
// Re-export public functions - parser
pub use parser::{load_from_file, parse_toml};
// Re-export public functions - executor
// Re-export public functions - executor (async backend paths, always available)
pub use executor::{
execute, execute_with_backend, execute_with_backend_complete, execute_with_backend_from_dir,
execute_with_backend, execute_with_backend_complete, execute_with_backend_from_dir,
execute_with_backend_i18n, execute_with_backend_i18n_with_defaults,
execute_with_backend_two_phase, execute_with_backend_two_phase_with_defaults,
execute_with_base_dir, load_and_execute_from_file, recompute_visible_elements,
render_display_item,
recompute_visible_elements, render_display_item,
};
// Re-export legacy sync paths (CLI-only: use InquireBackend directly)
#[cfg(feature = "cli")]
pub use executor::{execute, execute_with_base_dir, load_and_execute_from_file};
// Re-export public functions - conditions
pub use conditions::{
evaluate_condition, identify_selector_fields, should_load_fragment, value_to_string,

View File

@ -22,17 +22,11 @@
//!
//! ```no_run
//! use typedialog_core::prelude::*;
//! use typedialog_core::form_parser;
//!
//! # async fn example() -> Result<()> {
//! // Create a backend and execute forms
//! let mut backend = BackendFactory::create(BackendType::Cli)?;
//! let form = FormDefinition {
//! title: Some("User Registration".to_string()),
//! description: None,
//! locale: None,
//! fields: vec![],
//! display_items: vec![],
//! };
//! let form = form_parser::parse_toml(r#"name = "User Registration""#)?;
//! # Ok(())
//! # }
//! ```
@ -44,7 +38,7 @@
//!
//! fn example() -> Result<()> {
//! let name = prompt_api::text("Enter your name", None, None)?;
//! let confirmed = prompt_api::confirm("Continue?", Some(true))?;
//! let confirmed = prompt_api::confirm("Continue?", Some(true), None)?;
//! println!("Hello, {}!", name);
//! Ok(())
//! }

View File

@ -481,8 +481,8 @@ impl RoundtripConfig {
}
}
// Execute field using CLI prompts (from execute_with_base_dir logic)
let value = Self::execute_field_cli(field, &results)?;
let value = crate::backends::cli::InquireBackend::new()
.execute_field_sync(field, &results)?;
results.insert(field.name.clone(), value.clone());
}
}
@ -491,202 +491,6 @@ impl RoundtripConfig {
Ok(results)
}
/// Execute a single field using CLI prompts (extracted from executor.rs)
fn execute_field_cli(
field: &form_parser::FieldDefinition,
previous_results: &HashMap<String, Value>,
) -> Result<Value> {
use crate::prompts;
let is_required = field.required.unwrap_or(false);
let required_marker = if is_required { " *" } else { " (optional)" };
match field.field_type {
form_parser::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_cli(field, previous_results); // Retry
}
Ok(serde_json::json!(result))
}
form_parser::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))
}
form_parser::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_cli(field, previous_results); // Retry
}
Ok(serde_json::json!(result))
}
form_parser::FieldType::Select => {
if field.options.is_empty() {
return Err(crate::error::ErrorWrapper::new(
"Select field requires 'options'".to_string(),
));
}
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
// Filter options based on options_from if specified
let filtered_options = Self::filter_options_from(field, previous_results);
if filtered_options.is_empty() {
return Err(crate::error::ErrorWrapper::new(format!(
"No options available for field '{}'. Check options_from reference.",
field.name
)));
}
let options = filtered_options
.iter()
.map(|opt| opt.as_string())
.collect::<Vec<_>>();
let result = prompts::select(
&prompt_with_marker,
options,
field.page_size,
field.vim_mode.unwrap_or(false),
)?;
Ok(serde_json::json!(result))
}
form_parser::FieldType::MultiSelect => {
if field.options.is_empty() {
return Err(crate::error::ErrorWrapper::new(
"MultiSelect field requires 'options'".to_string(),
));
}
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let options = field
.options
.iter()
.map(|opt| opt.as_string())
.collect::<Vec<_>>();
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_cli(field, previous_results); // Retry
}
Ok(serde_json::json!(results))
}
form_parser::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_cli(field, previous_results); // Retry
}
Ok(serde_json::json!(result))
}
form_parser::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))
}
form_parser::FieldType::Custom => {
let prompt_with_marker = format!("{}{}", field.prompt, required_marker);
let type_name = field.custom_type.as_ref().ok_or_else(|| {
crate::error::ErrorWrapper::new(
"Custom field requires 'custom_type'".to_string(),
)
})?;
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_cli(field, previous_results); // Retry
}
Ok(serde_json::json!(result))
}
form_parser::FieldType::RepeatingGroup => Err(crate::error::ErrorWrapper::new(
"RepeatingGroup not yet implemented".to_string(),
)),
}
}
/// Filter field options based on options_from reference (extracted from executor.rs)
fn filter_options_from(
field: &form_parser::FieldDefinition,
previous_results: &HashMap<String, Value>,
) -> Vec<form_parser::SelectOption> {
// If no options_from specified, return all options
let Some(ref source_field) = field.options_from else {
return field.options.clone();
};
// Get the source field's value
let Some(source_value) = previous_results.get(source_field) else {
// Source field not found, return all options
return field.options.clone();
};
// Extract selected values from source field (could be array or comma-separated string)
let selected_values: Vec<String> = match source_value {
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
Value::String(s) => s.split(',').map(|item| item.trim().to_string()).collect(),
_ => return field.options.clone(), // Unsupported type, return all
};
// Filter options to only include those in selected_values
field
.options
.iter()
.filter(|opt| selected_values.contains(&opt.value))
.cloned()
.collect()
}
/// Load defaults from input Nickel file using form field nickel_path
fn load_defaults_from_input(
input_path: &Path,

View File

@ -7,16 +7,11 @@
//!
//! ```no_run
//! use typedialog_core::prelude::*;
//! use typedialog_core::form_parser;
//!
//! async fn example() -> Result<()> {
//! let mut backend = BackendFactory::create(BackendType::Cli)?;
//! let form = FormDefinition {
//! title: Some("Example Form".to_string()),
//! description: None,
//! locale: None,
//! fields: vec![],
//! display_items: vec![],
//! };
//! let form = form_parser::parse_toml(r#"name = "Example Form""#)?;
//! Ok(())
//! }
//! ```

View File

@ -10,7 +10,7 @@
//!
//! fn example() -> Result<()> {
//! let name = prompt_api::text("Enter your name", None, None)?;
//! let confirmed = prompt_api::confirm("Continue?", Some(true))?;
//! let confirmed = prompt_api::confirm("Continue?", Some(true), None)?;
//! println!("Hello, {}!", name);
//! Ok(())
//! }