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
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:
parent
78e995177b
commit
d7ebcf7c5e
42
CHANGELOG.md
42
CHANGELOG.md
@ -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**
|
||||
|
||||
@ -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" }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(())
|
||||
//! }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
@ -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(())
|
||||
//! }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user