chore: auto-format detection and cascade file path resolution, defaults multiformat
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 / Benchmark (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / License Compliance (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

This commit is contained in:
Jesús Pérez 2025-12-27 00:16:14 +00:00
parent f490760395
commit e58905c1f1
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
2 changed files with 214 additions and 13 deletions

View File

@ -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<PathBuf>,
@ -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<String, serde_json::Value> = 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_parser::FieldDefinition> = 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());

View File

@ -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<String, serde_json::Value> = 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,