From e58905c1f1be3b3d6a44511fb21b75924450f05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Sat, 27 Dec 2025 00:16:14 +0000 Subject: [PATCH] chore: auto-format detection and cascade file path resolution, defaults multiformat --- crates/typedialog-web/src/main.rs | 117 +++++++++++++++++++++++-- crates/typedialog/src/commands/form.rs | 110 +++++++++++++++++++++-- 2 files changed, 214 insertions(+), 13 deletions(-) diff --git a/crates/typedialog-web/src/main.rs b/crates/typedialog-web/src/main.rs index 0147b91..2880366 100644 --- a/crates/typedialog-web/src/main.rs +++ b/crates/typedialog-web/src/main.rs @@ -7,7 +7,7 @@ use clap::{Parser, Subcommand}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use typedialog_core::backends::{BackendFactory, BackendType}; use typedialog_core::cli_common; use typedialog_core::config::{load_backend_config, TypeDialogConfig}; @@ -96,6 +96,42 @@ enum Commands { }, } +/// Resolve a file path with cascading search relative to form directory +/// +/// Search order: +/// 1. If absolute path → use directly +/// 2. If path exists as-is (relative to cwd) → use it +/// 3. Try relative to form's directory → use if exists +/// 4. For output files → prefer form's directory +/// 5. For input files → return original (will fail with clear error) +/// +/// Returns the resolved PathBuf. +fn resolve_file_path(path: &Path, form_base_dir: &Path, is_output: bool) -> PathBuf { + // 1. If absolute, use it directly + if path.is_absolute() { + return path.to_path_buf(); + } + + // 2. Check if path exists as-is (relative to cwd) + if path.exists() { + return path.to_path_buf(); + } + + // 3. Try relative to form's directory + let form_relative = form_base_dir.join(path); + if form_relative.exists() { + return form_relative; + } + + // 4. For output files, prefer form directory even if doesn't exist yet + if is_output { + return form_relative; + } + + // 5. Return original path (will fail with clear error for input files) + path.to_path_buf() +} + /// Recursively flatten nested JSON objects into a single-level map /// Converts {"a": {"b": {"c": "value"}}} to {"a_b_c": "value"} fn flatten_json_object( @@ -227,6 +263,29 @@ async fn main() -> Result<()> { Ok(()) } +/// Detect output format from filename extension +/// +/// Returns the format string based on file extension: +/// - `.toml` → "toml" +/// - `.yaml` or `.yml` → "yaml" +/// - `.json` → "json" +/// - `.txt` → "text" +/// - default → provided format +fn detect_output_format(output_path: Option<&PathBuf>, default_format: &str) -> String { + output_path + .and_then(|p| p.extension()) + .and_then(|ext| ext.to_str()) + .and_then(|ext| match ext.to_lowercase().as_str() { + "toml" => Some("toml"), + "yaml" | "yml" => Some("yaml"), + "json" => Some("json"), + "txt" => Some("text"), + _ => None, + }) + .unwrap_or(default_format) + .to_string() +} + async fn execute_form( config: PathBuf, defaults: Option, @@ -239,6 +298,9 @@ async fn execute_form( let mut form = form_parser::parse_toml(&toml_content)?; + // Auto-detect format from output filename if not explicitly json + let actual_format = detect_output_format(output.as_ref(), format); + // Web backend uses unified elements array internally, migrate if using legacy format form.migrate_to_elements(); @@ -248,11 +310,15 @@ async fn execute_form( // Expand groups with includes to load fragment files form = form_parser::expand_includes(form, base_dir)?; - // Load default values from JSON or .ncl file if provided - let initial_values = if let Some(defaults_path) = defaults { + // Load default values from JSON, TOML, or .ncl file if provided + let initial_values = if let Some(defaults_path_input) = defaults { + // Resolve defaults path with cascading search + let defaults_path = resolve_file_path(&defaults_path_input, base_dir, false); use typedialog_core::nickel::NickelCli; - let is_ncl = defaults_path.extension().and_then(|s| s.to_str()) == Some("ncl"); + let extension = defaults_path.extension().and_then(|s| s.to_str()); + let is_ncl = extension == Some("ncl"); + let is_toml = extension == Some("toml"); let defaults_json: std::collections::HashMap = if is_ncl { // Convert .ncl to JSON using nickel export @@ -286,6 +352,43 @@ async fn execute_form( )) } } + } else if is_toml { + // Read TOML and convert to JSON + let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| { + Error::validation_failed(format!("Failed to read defaults file: {}", e)) + })?; + let toml_value: toml::Value = toml::from_str(&defaults_content).map_err(|e| { + Error::validation_failed(format!("Failed to parse defaults TOML: {}", e)) + })?; + // Convert TOML to JSON + let parsed: serde_json::Value = serde_json::to_value(&toml_value).map_err(|e| { + Error::validation_failed(format!("Failed to convert TOML to JSON: {}", e)) + })?; + match parsed { + serde_json::Value::Object(map) => { + // Extract fields from elements (which includes fragments after expand_includes) + let form_fields: Vec = form + .elements + .iter() + .filter_map(|elem| match elem { + form_parser::FormElement::Field(f) => Some(f.clone()), + _ => None, + }) + .collect(); + + let mut combined = extract_nickel_defaults(&map, &form_fields); + let flattened = flatten_json_object(&map); + for (k, v) in flattened { + combined.entry(k).or_insert(v); + } + combined + } + _ => { + return Err(Error::validation_failed( + "Defaults TOML must be a table/object".to_string(), + )) + } + } } else { // Read JSON directly - combine extraction and flatten let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| { @@ -377,8 +480,10 @@ async fn execute_form( }; // Save results to output file if specified - if let Some(out_path) = output { - let output_str = helpers::format_results(&results, format)?; + if let Some(output_path_input) = output { + // Resolve output path with cascading search (will create if doesn't exist) + let out_path = resolve_file_path(&output_path_input, base_dir, true); + let output_str = helpers::format_results(&results, &actual_format)?; fs::write(&out_path, output_str) .map_err(|e| Error::validation_failed(format!("Failed to write output file: {}", e)))?; eprintln!("[web] Results saved to: {}", out_path.display()); diff --git a/crates/typedialog/src/commands/form.rs b/crates/typedialog/src/commands/form.rs index f8e1e9b..4432832 100644 --- a/crates/typedialog/src/commands/form.rs +++ b/crates/typedialog/src/commands/form.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use typedialog_core::backends::BackendFactory; use typedialog_core::config::TypeDialogConfig; use typedialog_core::i18n::{I18nBundle, LocaleLoader, LocaleResolver}; @@ -12,6 +12,63 @@ use unic_langid::LanguageIdentifier; use super::helpers::{extract_nickel_defaults, flatten_json_object, print_results}; +/// Detect output format from filename extension +/// +/// Returns the format string based on file extension: +/// - `.toml` → "toml" +/// - `.yaml` or `.yml` → "yaml" +/// - `.json` → "json" +/// - `.txt` → "text" +/// - default → provided format +fn detect_output_format(output_path: Option<&PathBuf>, default_format: &str) -> String { + output_path + .and_then(|p| p.extension()) + .and_then(|ext| ext.to_str()) + .and_then(|ext| match ext.to_lowercase().as_str() { + "toml" => Some("toml"), + "yaml" | "yml" => Some("yaml"), + "json" => Some("json"), + "txt" => Some("text"), + _ => None, + }) + .unwrap_or(default_format) + .to_string() +} + +/// Resolve a file path with cascading search relative to form directory +/// +/// Search order: +/// 1. If absolute path → use directly +/// 2. If path exists as-is (relative to cwd) → use it +/// 3. Try relative to form's directory → use if exists +/// 4. For output files → prefer form's directory +/// 5. For input files → return original (will fail with clear error) +fn resolve_file_path(path: &Path, form_base_dir: &Path, is_output: bool) -> PathBuf { + // 1. If absolute, use it directly + if path.is_absolute() { + return path.to_path_buf(); + } + + // 2. Check if path exists as-is (relative to cwd) + if path.exists() { + return path.to_path_buf(); + } + + // 3. Try relative to form's directory + let form_relative = form_base_dir.join(path); + if form_relative.exists() { + return form_relative; + } + + // 4. For output files, prefer form directory even if doesn't exist yet + if is_output { + return form_relative; + } + + // 5. Return original path (will fail with clear error for input files) + path.to_path_buf() +} + #[allow(clippy::too_many_arguments)] pub async fn execute_form( config: PathBuf, @@ -32,10 +89,18 @@ pub async fn execute_form( let form = form_parser::parse_toml(&toml_content)?; let base_dir = config.parent().unwrap_or_else(|| std::path::Path::new(".")); - // Load default values from JSON or .ncl file if provided - let initial_values = if let Some(defaults_path) = defaults { + // Auto-detect format from output filename if not explicitly specified + let actual_format = detect_output_format(output_file.as_ref(), format); + + // Load default values from JSON, TOML, or .ncl file if provided + let initial_values = if let Some(defaults_path_input) = defaults { + // Resolve defaults path with cascading search + let defaults_path = resolve_file_path(&defaults_path_input, base_dir, false); NickelCli::verify()?; - let is_ncl = defaults_path.extension().and_then(|s| s.to_str()) == Some("ncl"); + + let extension = defaults_path.extension().and_then(|s| s.to_str()); + let is_ncl = extension == Some("ncl"); + let is_toml = extension == Some("toml"); let defaults_json: HashMap = if is_ncl { let value = NickelCli::export(&defaults_path)?; @@ -55,7 +120,36 @@ pub async fn execute_form( )) } } + } else if is_toml { + // Read TOML and convert to JSON + let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| { + Error::validation_failed(format!("Failed to read defaults file: {}", e)) + })?; + let toml_value: toml::Value = toml::from_str(&defaults_content).map_err(|e| { + Error::validation_failed(format!("Failed to parse defaults TOML: {}", e)) + })?; + // Convert TOML to JSON + let parsed: serde_json::Value = serde_json::to_value(&toml_value).map_err(|e| { + Error::validation_failed(format!("Failed to convert TOML to JSON: {}", e)) + })?; + match parsed { + serde_json::Value::Object(map) => { + let extracted = extract_nickel_defaults(&map, &form.fields); + let flattened = flatten_json_object(&map); + let mut combined = extracted; + for (k, v) in flattened { + combined.entry(k).or_insert(v); + } + combined + } + _ => { + return Err(Error::validation_failed( + "Defaults TOML must be a table/object".to_string(), + )) + } + } } else { + // Read JSON directly let defaults_content = fs::read_to_string(&defaults_path).map_err(|e| { Error::validation_failed(format!("Failed to read defaults file: {}", e)) })?; @@ -144,8 +238,10 @@ pub async fn execute_form( let mut engine = TemplateEngine::new(); let nickel_output = engine.render_file(template_path.as_path(), &results, None)?; - if let Some(path) = output_file { - fs::write(path, &nickel_output).map_err(Error::io)?; + if let Some(output_path_input) = output_file { + // Resolve output path with cascading search + let path = resolve_file_path(output_path_input, base_dir, true); + fs::write(&path, &nickel_output).map_err(Error::io)?; } else { println!("{}", nickel_output); } @@ -174,7 +270,7 @@ pub async fn execute_form( let config = TypeDialogConfig::default(); print_results( &results, - format, + &actual_format, output_file, &form_fields, &encryption_context,