chore: fix load defaults in backends
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
This commit is contained in:
parent
b906168f6d
commit
87de90afa9
@ -201,6 +201,10 @@ impl FormBackend for AiBackend {
|
||||
fn name(&self) -> &str {
|
||||
"ai"
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AiBackend {
|
||||
|
||||
@ -441,6 +441,10 @@ impl FormBackend for InquireBackend {
|
||||
self.execute_field_sync(field)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<()> {
|
||||
// No cleanup needed for CLI backend
|
||||
Ok(())
|
||||
|
||||
@ -76,6 +76,9 @@ pub trait FormBackend: Send + Sync {
|
||||
/// Cleanup/shutdown the backend
|
||||
async fn shutdown(&mut self) -> Result<()>;
|
||||
|
||||
/// Downcast to concrete type (for accessing backend-specific features)
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
|
||||
/// Check if this backend is available on the current system
|
||||
fn is_available() -> bool
|
||||
where
|
||||
|
||||
@ -628,6 +628,10 @@ impl FormBackend for RatatuiBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shutdown terminal - cleanup happens via Drop trait on _guard
|
||||
async fn shutdown(&mut self) -> Result<()> {
|
||||
*self.terminal.write().unwrap() = None;
|
||||
|
||||
@ -20,6 +20,14 @@ use crate::form_parser::{DisplayItem, FieldDefinition, FieldType};
|
||||
/// Type alias for complete form submission channel
|
||||
type CompleteFormChannel = Arc<RwLock<Option<oneshot::Sender<HashMap<String, Value>>>>>;
|
||||
|
||||
/// Roundtrip context for nickel-roundtrip mode
|
||||
#[derive(Clone)]
|
||||
pub struct RoundtripContext {
|
||||
pub initial_values: HashMap<String, Value>,
|
||||
pub output_path: PathBuf,
|
||||
pub input_nickel: String,
|
||||
}
|
||||
|
||||
/// Shared form state accessible to all handlers (A-SHARED-STATE pattern)
|
||||
#[derive(Clone)]
|
||||
pub struct WebFormState {
|
||||
@ -39,6 +47,8 @@ pub struct WebFormState {
|
||||
complete_items: Arc<RwLock<Vec<DisplayItem>>>,
|
||||
/// Base directory for lazy loading fragments (FASE 4)
|
||||
base_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||
/// Roundtrip context (only set in nickel-roundtrip mode)
|
||||
roundtrip_context: Arc<RwLock<Option<RoundtripContext>>>,
|
||||
}
|
||||
|
||||
impl WebFormState {
|
||||
@ -54,8 +64,15 @@ impl WebFormState {
|
||||
complete_fields: Arc::new(RwLock::new(Vec::new())),
|
||||
complete_items: Arc::new(RwLock::new(Vec::new())),
|
||||
base_dir: Arc::new(RwLock::new(None)),
|
||||
roundtrip_context: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set roundtrip context for nickel-roundtrip mode
|
||||
pub async fn set_roundtrip_context(&self, context: RoundtripContext) {
|
||||
let mut ctx = self.roundtrip_context.write().await;
|
||||
*ctx = Some(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Web Backend implementation using axum
|
||||
@ -85,6 +102,11 @@ impl WebBackend {
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get access to the web form state (for setting roundtrip context)
|
||||
pub fn get_state(&self) -> Option<Arc<WebFormState>> {
|
||||
self.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
@ -118,6 +140,7 @@ impl FormBackend for WebBackend {
|
||||
.route("/api/form/dynamic", get(get_dynamic_form_handler))
|
||||
.route("/api/field/{name}", post(submit_field_handler))
|
||||
.route("/api/form/complete", post(submit_complete_form_handler))
|
||||
.route("/api/form/download", get(download_config_handler))
|
||||
.route("/api/nickel/to-form", post(nickel_to_form_handler))
|
||||
.route("/api/nickel/template", post(nickel_template_handler))
|
||||
.route("/api/nickel/roundtrip", post(nickel_roundtrip_handler))
|
||||
@ -399,6 +422,10 @@ impl FormBackend for WebBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<()> {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@ -849,14 +876,87 @@ async fn submit_complete_form_handler(
|
||||
// Update state results
|
||||
{
|
||||
let mut results = state.results.write().await;
|
||||
*results = all_results;
|
||||
*results = all_results.clone();
|
||||
}
|
||||
|
||||
// A-TYPED-RESPONSES: Build response with HX-Trigger header
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Trigger", "formComplete".parse().unwrap());
|
||||
// Check if we're in roundtrip mode
|
||||
let roundtrip_ctx = state.roundtrip_context.read().await;
|
||||
if let Some(ctx) = roundtrip_ctx.as_ref() {
|
||||
// Roundtrip mode: return HTML summary page
|
||||
use crate::nickel::summary::RoundtripSummary;
|
||||
|
||||
(StatusCode::OK, headers, Json(json!({"success": true})))
|
||||
let summary = RoundtripSummary::from_values(
|
||||
&ctx.initial_values,
|
||||
&all_results,
|
||||
None, // Validation will be done after template rendering
|
||||
ctx.output_path.display().to_string(),
|
||||
);
|
||||
|
||||
let html = summary.render_html();
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap()
|
||||
} else {
|
||||
// Normal mode: return JSON success
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("HX-Trigger", "formComplete")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&json!({"success": true})).unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
async fn download_config_handler(State(state): State<Arc<WebFormState>>) -> impl IntoResponse {
|
||||
use axum::http::header;
|
||||
|
||||
// Get roundtrip context to find output path
|
||||
let roundtrip_ctx = state.roundtrip_context.read().await;
|
||||
if let Some(ctx) = roundtrip_ctx.as_ref() {
|
||||
// Read the generated config file
|
||||
match std::fs::read_to_string(&ctx.output_path) {
|
||||
Ok(content) => {
|
||||
let filename = ctx
|
||||
.output_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("config.ncl");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());
|
||||
|
||||
(StatusCode::OK, headers, content)
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
HeaderMap::new(),
|
||||
"Config file not found".to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
HeaderMap::new(),
|
||||
"Download only available in roundtrip mode".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
@ -1376,17 +1476,30 @@ fn render_field_for_complete_form(
|
||||
FieldType::Custom => {
|
||||
let custom_type = field.custom_type.as_deref().unwrap_or("String");
|
||||
let placeholder = field.placeholder.as_deref().unwrap_or("");
|
||||
let default = field.default.as_deref().unwrap_or("");
|
||||
let type_hint = format!("Type: {}", custom_type);
|
||||
|
||||
// Use value from results if available (e.g., from initial_values), otherwise use default
|
||||
let current_value = results
|
||||
.get(&field.name)
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.or_else(|| {
|
||||
results
|
||||
.get(&field.name)
|
||||
.map(|v| v.to_string().trim_matches('"').to_string())
|
||||
})
|
||||
.unwrap_or_else(|| default.to_string());
|
||||
|
||||
(
|
||||
format!(
|
||||
r#"<div class="field" style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">{}</label>
|
||||
<input type="text" name="{}" placeholder="{}" title="{}" style="width: 100%; padding: 8px; background: #1e1e1e; color: #d4d4d4; border: 1px solid #3e3e42; box-sizing: border-box;" {} >
|
||||
<input type="text" name="{}" value="{}" placeholder="{}" title="{}" style="width: 100%; padding: 8px; background: #1e1e1e; color: #d4d4d4; border: 1px solid #3e3e42; box-sizing: border-box;" {} >
|
||||
<small style="color: #888; display: block; margin-top: 5px;">{}</small>
|
||||
</div>"#,
|
||||
html_escape(&field.prompt),
|
||||
html_escape(&field.name),
|
||||
html_escape(¤t_value),
|
||||
html_escape(placeholder),
|
||||
html_escape(&type_hint),
|
||||
if field.required.unwrap_or(false) {
|
||||
|
||||
@ -297,7 +297,8 @@ fn extract_numeric(v: &serde_json::Value) -> Option<f64> {
|
||||
}
|
||||
|
||||
/// Convert JSON value to string for comparison
|
||||
pub(super) fn value_to_string(v: &serde_json::Value) -> String {
|
||||
/// Convert a serde_json::Value to a String suitable for form field defaults
|
||||
pub fn value_to_string(v: &serde_json::Value) -> String {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
|
||||
@ -8,7 +8,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
use super::conditions::evaluate_condition;
|
||||
use super::parser::{load_elements_from_file, load_fields_from_file, load_items_from_file};
|
||||
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,
|
||||
@ -82,7 +82,7 @@ pub fn render_display_item(item: &DisplayItem, results: &HashMap<String, serde_j
|
||||
}
|
||||
|
||||
pub fn execute_with_base_dir(
|
||||
form: FormDefinition,
|
||||
mut form: FormDefinition,
|
||||
base_dir: &Path,
|
||||
) -> Result<HashMap<String, serde_json::Value>> {
|
||||
let mut results = HashMap::new();
|
||||
@ -94,99 +94,36 @@ pub fn execute_with_base_dir(
|
||||
println!("\n{}\n", form.name);
|
||||
}
|
||||
|
||||
// Expand groups with includes and build ordered element map
|
||||
// Migrate legacy format to unified elements
|
||||
form.migrate_to_elements();
|
||||
|
||||
// Expand groups with includes using the unified expand_includes function
|
||||
let expanded_form = super::fragments::expand_includes(form, base_dir)?;
|
||||
|
||||
// Build ordered element map
|
||||
let mut element_map: BTreeMap<usize, FormElement> = BTreeMap::new();
|
||||
let mut order_counter = 0;
|
||||
|
||||
// Process items (expand groups and assign order if not specified)
|
||||
for item in form.items.iter() {
|
||||
let mut item_clone = item.clone();
|
||||
|
||||
// Handle group type with includes
|
||||
if item.item_type == "group" {
|
||||
let group_order = item.order;
|
||||
let group_condition = item.when.clone(); // Capture group's when condition
|
||||
if let Some(includes) = &item.includes {
|
||||
// Load items and fields from included files
|
||||
// Use group_order * 100 + relative_order to avoid collisions
|
||||
let mut group_item_counter = 1;
|
||||
|
||||
for include_path in includes {
|
||||
// Try loading items first
|
||||
match load_items_from_file(include_path, base_dir) {
|
||||
Ok(loaded_items) => {
|
||||
for mut loaded_item in loaded_items {
|
||||
// Propagate group's when condition to loaded items if group has a condition
|
||||
if let Some(ref condition) = group_condition {
|
||||
if loaded_item.when.is_none() {
|
||||
loaded_item.when = Some(condition.clone());
|
||||
}
|
||||
}
|
||||
// Adjust order: use group_order as base (multiplied by 100)
|
||||
// plus item's relative order from fragment
|
||||
let relative_order = if loaded_item.order > 0 {
|
||||
loaded_item.order
|
||||
} else {
|
||||
group_item_counter
|
||||
};
|
||||
loaded_item.order = group_order * 100 + relative_order;
|
||||
group_item_counter += 1;
|
||||
element_map
|
||||
.insert(loaded_item.order, FormElement::Item(loaded_item));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ ERROR: Failed to load include '{}': {}", include_path, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
// Try loading fields
|
||||
match load_fields_from_file(include_path, base_dir) {
|
||||
Ok(loaded_fields) => {
|
||||
for mut loaded_field in loaded_fields {
|
||||
// Propagate group's when condition to loaded fields if group has a condition
|
||||
if let Some(ref condition) = group_condition {
|
||||
if loaded_field.when.is_none() {
|
||||
loaded_field.when = Some(condition.clone());
|
||||
}
|
||||
}
|
||||
// Same approach for fields
|
||||
let relative_order = if loaded_field.order > 0 {
|
||||
loaded_field.order
|
||||
} else {
|
||||
group_item_counter
|
||||
};
|
||||
loaded_field.order = group_order * 100 + relative_order;
|
||||
group_item_counter += 1;
|
||||
element_map
|
||||
.insert(loaded_field.order, FormElement::Field(loaded_field));
|
||||
}
|
||||
}
|
||||
Err(_e) => {
|
||||
// Fields might not exist in this file, that's ok
|
||||
}
|
||||
}
|
||||
for element in expanded_form.elements {
|
||||
let order = match &element {
|
||||
FormElement::Item(item) => {
|
||||
if item.order == 0 {
|
||||
order_counter += 1;
|
||||
order_counter - 1
|
||||
} else {
|
||||
item.order
|
||||
}
|
||||
}
|
||||
// Don't add group item itself to the map
|
||||
} else {
|
||||
// Regular item
|
||||
if item_clone.order == 0 {
|
||||
item_clone.order = order_counter;
|
||||
order_counter += 1;
|
||||
FormElement::Field(field) => {
|
||||
if field.order == 0 {
|
||||
order_counter += 1;
|
||||
order_counter - 1
|
||||
} else {
|
||||
field.order
|
||||
}
|
||||
}
|
||||
element_map.insert(item_clone.order, FormElement::Item(item_clone));
|
||||
}
|
||||
}
|
||||
|
||||
// Add form fields to the element map
|
||||
for field in form.fields.clone() {
|
||||
let mut field_clone = field.clone();
|
||||
if field_clone.order == 0 {
|
||||
field_clone.order = order_counter;
|
||||
order_counter += 1;
|
||||
}
|
||||
element_map.insert(field_clone.order, FormElement::Field(field_clone));
|
||||
};
|
||||
element_map.insert(order, element);
|
||||
}
|
||||
|
||||
// Process elements in order
|
||||
@ -728,7 +665,21 @@ pub async fn execute_with_backend_two_phase_with_defaults(
|
||||
}
|
||||
|
||||
// PHASE 2: Build element list with lazy loading based on Phase 1 results
|
||||
let element_list = build_element_list(&form, base_dir, &results)?;
|
||||
let mut element_list = build_element_list(&form, base_dir, &results)?;
|
||||
|
||||
// Apply initial_values to field.default for all expanded elements
|
||||
// This ensures defaults from nickel-roundtrip input files are shown in the UI
|
||||
if let Some(ref init_vals) = initial_backup {
|
||||
for (_, element) in element_list.iter_mut() {
|
||||
if let FormElement::Field(field) = element {
|
||||
if let Some(value) = init_vals.get(&field.name) {
|
||||
if field.default.is_none() {
|
||||
field.default = Some(super::conditions::value_to_string(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 3: Execute remaining fields (non-selectors)
|
||||
let mut context = RenderContext {
|
||||
@ -833,18 +784,36 @@ pub async fn execute_with_backend_i18n_with_defaults(
|
||||
// Check display mode and execute accordingly
|
||||
if form.display_mode == DisplayMode::Complete {
|
||||
// Complete mode: show all fields at once
|
||||
// Filter items by 'when' condition
|
||||
let items: Vec<&DisplayItem> = element_list
|
||||
.iter()
|
||||
.filter_map(|(_, e)| match e {
|
||||
FormElement::Item(item) => Some(item),
|
||||
FormElement::Item(item) => {
|
||||
// Evaluate 'when' condition if present
|
||||
if let Some(condition) = &item.when {
|
||||
if !evaluate_condition(condition, &results) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(item)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Filter fields by 'when' condition
|
||||
let fields: Vec<&FieldDefinition> = element_list
|
||||
.iter()
|
||||
.filter_map(|(_, e)| match e {
|
||||
FormElement::Field(field) => Some(field),
|
||||
FormElement::Field(field) => {
|
||||
// Evaluate 'when' condition if present
|
||||
if let Some(condition) = &field.when {
|
||||
if !evaluate_condition(condition, &results) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(field)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -35,7 +35,9 @@ pub use executor::{
|
||||
};
|
||||
|
||||
// Re-export public functions - conditions
|
||||
pub use conditions::{evaluate_condition, identify_selector_fields, should_load_fragment};
|
||||
pub use conditions::{
|
||||
evaluate_condition, identify_selector_fields, should_load_fragment, value_to_string,
|
||||
};
|
||||
|
||||
// Re-export public functions - fragments
|
||||
pub use fragments::{expand_includes, load_fragment_form};
|
||||
|
||||
@ -172,35 +172,3 @@ pub(super) fn load_elements_from_file(
|
||||
form.migrate_to_elements();
|
||||
Ok(form.elements)
|
||||
}
|
||||
|
||||
/// Load items from a TOML file with proper path resolution
|
||||
/// (For backward compatibility - prefer load_elements_from_file for new code)
|
||||
/// Searches for fragments in base_dir first, then TYPEDIALOG_FRAGMENT_PATH directories.
|
||||
pub(super) fn load_items_from_file(
|
||||
path: &str,
|
||||
base_dir: &Path,
|
||||
) -> Result<Vec<super::types::DisplayItem>> {
|
||||
let resolved_path = resolve_fragment_path(path, base_dir);
|
||||
let content = std::fs::read_to_string(&resolved_path)?;
|
||||
|
||||
// Resolve constraint interpolations before parsing
|
||||
let resolved_content = resolve_constraints_in_content(&content, base_dir)?;
|
||||
let form: FormDefinition = toml::from_str(&resolved_content)?;
|
||||
Ok(form.items)
|
||||
}
|
||||
|
||||
/// Load fields from a TOML file with proper path resolution
|
||||
/// (For backward compatibility - prefer load_elements_from_file for new code)
|
||||
/// Searches for fragments in base_dir first, then TYPEDIALOG_FRAGMENT_PATH directories.
|
||||
pub(super) fn load_fields_from_file(
|
||||
path: &str,
|
||||
base_dir: &Path,
|
||||
) -> Result<Vec<super::types::FieldDefinition>> {
|
||||
let resolved_path = resolve_fragment_path(path, base_dir);
|
||||
let content = std::fs::read_to_string(&resolved_path)?;
|
||||
|
||||
// Resolve constraint interpolations before parsing
|
||||
let resolved_content = resolve_constraints_in_content(&content, base_dir)?;
|
||||
let form: FormDefinition = toml::from_str(&resolved_content)?;
|
||||
Ok(form.fields)
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ pub mod parser;
|
||||
pub mod roundtrip;
|
||||
pub mod schema_ir;
|
||||
pub mod serializer;
|
||||
pub mod summary;
|
||||
pub mod template_engine;
|
||||
pub mod template_renderer;
|
||||
pub mod toml_generator;
|
||||
@ -56,6 +57,7 @@ pub use parser::MetadataParser;
|
||||
pub use roundtrip::{RoundtripConfig, RoundtripResult};
|
||||
pub use schema_ir::{ContractCall, EncryptionMetadata, NickelFieldIR, NickelSchemaIR, NickelType};
|
||||
pub use serializer::NickelSerializer;
|
||||
pub use summary::{FieldChange, RoundtripSummary};
|
||||
pub use template_engine::TemplateEngine;
|
||||
pub use template_renderer::NickelTemplateContext;
|
||||
pub use toml_generator::TomlGenerator;
|
||||
|
||||
414
crates/typedialog-core/src/nickel/summary.rs
Normal file
414
crates/typedialog-core/src/nickel/summary.rs
Normal file
@ -0,0 +1,414 @@
|
||||
//! Roundtrip summary and diff generation
|
||||
//!
|
||||
//! Generates human-readable summaries of roundtrip operations showing
|
||||
//! what changed between input and output.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Summary of a roundtrip operation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoundtripSummary {
|
||||
/// Total number of fields processed
|
||||
pub total_fields: usize,
|
||||
|
||||
/// Number of fields that changed
|
||||
pub changed_fields: usize,
|
||||
|
||||
/// Number of fields unchanged
|
||||
pub unchanged_fields: usize,
|
||||
|
||||
/// List of field changes (field_name, old_value, new_value)
|
||||
pub changes: Vec<FieldChange>,
|
||||
|
||||
/// Validation status
|
||||
pub validation_passed: Option<bool>,
|
||||
|
||||
/// Output file path
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
/// Represents a change in a field value
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FieldChange {
|
||||
pub field_name: String,
|
||||
pub old_value: String,
|
||||
pub new_value: String,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
impl RoundtripSummary {
|
||||
/// Create a summary by comparing initial and final values
|
||||
pub fn from_values(
|
||||
initial_values: &HashMap<String, Value>,
|
||||
final_results: &HashMap<String, Value>,
|
||||
validation_passed: Option<bool>,
|
||||
output_path: String,
|
||||
) -> Self {
|
||||
let mut changes = Vec::new();
|
||||
let mut changed_count = 0;
|
||||
let mut unchanged_count = 0;
|
||||
|
||||
// Collect all field names from both maps
|
||||
let mut all_fields: Vec<String> = initial_values
|
||||
.keys()
|
||||
.chain(final_results.keys())
|
||||
.cloned()
|
||||
.collect();
|
||||
all_fields.sort();
|
||||
all_fields.dedup();
|
||||
|
||||
for field_name in all_fields {
|
||||
let old_val = initial_values.get(&field_name);
|
||||
let new_val = final_results.get(&field_name);
|
||||
|
||||
let old_str = value_to_display_string(old_val);
|
||||
let new_str = value_to_display_string(new_val);
|
||||
|
||||
let changed = old_str != new_str;
|
||||
if changed {
|
||||
changed_count += 1;
|
||||
} else {
|
||||
unchanged_count += 1;
|
||||
}
|
||||
|
||||
changes.push(FieldChange {
|
||||
field_name,
|
||||
old_value: old_str,
|
||||
new_value: new_str,
|
||||
changed,
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
total_fields: changes.len(),
|
||||
changed_fields: changed_count,
|
||||
unchanged_fields: unchanged_count,
|
||||
changes,
|
||||
validation_passed,
|
||||
output_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render summary as terminal text with colors
|
||||
pub fn render_terminal(&self, verbose: bool) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
output.push('\n');
|
||||
output.push_str("╔════════════════════════════════════════════════════════════╗\n");
|
||||
output.push_str("║ ✅ Configuration Saved Successfully! ║\n");
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
output.push_str(&format!(
|
||||
"║ 📄 File: {:<48} ║\n",
|
||||
truncate(&self.output_path, 48)
|
||||
));
|
||||
|
||||
if let Some(passed) = self.validation_passed {
|
||||
let status = if passed { "✓ PASSED" } else { "✗ FAILED" };
|
||||
output.push_str(&format!("║ ✓ Validation: {:<43} ║\n", status));
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
"║ 📊 Fields: {}/{} changed, {} unchanged{:<18} ║\n",
|
||||
self.changed_fields, self.total_fields, self.unchanged_fields, ""
|
||||
));
|
||||
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
|
||||
// Show changes
|
||||
if self.changed_fields > 0 {
|
||||
output.push_str("║ 📋 What Changed: ║\n");
|
||||
|
||||
let mut shown = 0;
|
||||
for change in &self.changes {
|
||||
if !change.changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !verbose && shown >= 10 {
|
||||
let remaining = self.changed_fields - shown;
|
||||
output.push_str(&format!(
|
||||
"║ ... and {} more changes (use --verbose to see all){:<4} ║\n",
|
||||
remaining, ""
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
let line = format!(
|
||||
" ├─ {}: {} → {}",
|
||||
change.field_name,
|
||||
truncate(&change.old_value, 15),
|
||||
truncate(&change.new_value, 15)
|
||||
);
|
||||
output.push_str(&format!("║ {:<58} ║\n", truncate(&line, 58)));
|
||||
shown += 1;
|
||||
}
|
||||
} else {
|
||||
output.push_str("║ 📋 No changes made ║\n");
|
||||
}
|
||||
|
||||
output.push_str("╠════════════════════════════════════════════════════════════╣\n");
|
||||
output.push_str("║ 💡 Next Steps: ║\n");
|
||||
output.push_str("║ • Review: cat config.ncl ║\n");
|
||||
output.push_str("║ • Apply CI tools: ./setup-ci.sh ║\n");
|
||||
output.push_str("║ • Re-configure: ./ci-configure.sh ║\n");
|
||||
output.push_str("╚════════════════════════════════════════════════════════════╝\n");
|
||||
output.push('\n');
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Render summary as HTML page for web backend
|
||||
pub fn render_html(&self) -> String {
|
||||
let validation_badge = match self.validation_passed {
|
||||
Some(true) => r#"<span style="color: #4ec9b0;">✓ PASSED</span>"#,
|
||||
Some(false) => r#"<span style="color: #f48771;">✗ FAILED</span>"#,
|
||||
None => r#"<span style="color: #808080;">N/A</span>"#,
|
||||
};
|
||||
|
||||
let changes_html = if self.changed_fields > 0 {
|
||||
let mut html = String::from("<div style='margin: 20px 0;'>");
|
||||
html.push_str("<h3 style='margin: 0 0 10px 0;'>📋 What Changed:</h3>");
|
||||
html.push_str("<div style='background: #1e1e1e; padding: 15px; border-radius: 4px; font-family: monospace;'>");
|
||||
|
||||
for change in &self.changes {
|
||||
if !change.changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
html.push_str(&format!(
|
||||
r#"<div style="margin: 8px 0;">
|
||||
<div style="color: #dcdcaa;">{}</div>
|
||||
<div style="margin-left: 20px; color: #f48771;">- {}</div>
|
||||
<div style="margin-left: 20px; color: #4ec9b0;">+ {}</div>
|
||||
</div>"#,
|
||||
html_escape(&change.field_name),
|
||||
html_escape(&change.old_value),
|
||||
html_escape(&change.new_value)
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</div></div>");
|
||||
html
|
||||
} else {
|
||||
String::from("<p style='color: #808080;'>No changes made</p>")
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Configuration Saved</title>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #252526;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}}
|
||||
h1 {{
|
||||
color: #4ec9b0;
|
||||
margin: 0 0 10px 0;
|
||||
}}
|
||||
.header {{
|
||||
border-bottom: 2px solid #007acc;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.stat-row {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.actions {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #3e3e42;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
background: #007acc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.btn:hover {{
|
||||
background: #005a9e;
|
||||
}}
|
||||
.next-steps {{
|
||||
background: #1e1e1e;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4ec9b0;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
.next-steps ul {{
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}}
|
||||
.auto-close {{
|
||||
color: #808080;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
<script>
|
||||
let countdown = 30;
|
||||
function updateCountdown() {{
|
||||
document.getElementById('countdown').textContent = countdown;
|
||||
countdown--;
|
||||
if (countdown < 0) {{
|
||||
window.close();
|
||||
}}
|
||||
}}
|
||||
setInterval(updateCountdown, 1000);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Configuration Saved Successfully!</h1>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span>📄 <strong>File:</strong></span>
|
||||
<span style="font-family: monospace;">{}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span>✓ <strong>Validation:</strong></span>
|
||||
<span>{}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span>📊 <strong>Fields Configured:</strong></span>
|
||||
<span>{} total ({} changed, {} unchanged)</span>
|
||||
</div>
|
||||
|
||||
{}
|
||||
|
||||
<div class="next-steps">
|
||||
<h3 style="margin: 0 0 10px 0;">💡 Next Steps:</h3>
|
||||
<ul>
|
||||
<li>Review configuration: <code>cat {}</code></li>
|
||||
<li>Apply CI tools: <code>./setup-ci.sh</code></li>
|
||||
<li>Re-configure anytime: <code>./ci-configure.sh</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="window.location.href='/api/form/download'">📥 Download Config</button>
|
||||
<button class="btn" onclick="window.location.reload()">🔄 Configure Again</button>
|
||||
<button class="btn" onclick="window.close()">❌ Close</button>
|
||||
</div>
|
||||
|
||||
<div class="auto-close">
|
||||
⏱️ This window will auto-close in <span id="countdown">30</span> seconds...
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
html_escape(&self.output_path),
|
||||
validation_badge,
|
||||
self.total_fields,
|
||||
self.changed_fields,
|
||||
self.unchanged_fields,
|
||||
changes_html,
|
||||
html_escape(&self.output_path)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a JSON Value to a display string
|
||||
fn value_to_display_string(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::Bool(b)) => b.to_string(),
|
||||
Some(Value::Array(arr)) => {
|
||||
let items: Vec<String> = arr
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
Some(Value::Null) => String::from("(empty)"),
|
||||
Some(Value::Object(_)) => String::from("{...}"),
|
||||
None => String::from("(not set)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate string to max length with ellipsis
|
||||
fn truncate(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML escape for safe rendering
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_summary_with_changes() {
|
||||
let initial = HashMap::from([
|
||||
("field1".to_string(), json!("value1")),
|
||||
("field2".to_string(), json!(42)),
|
||||
]);
|
||||
|
||||
let final_results = HashMap::from([
|
||||
("field1".to_string(), json!("value_changed")),
|
||||
("field2".to_string(), json!(42)),
|
||||
]);
|
||||
|
||||
let summary = RoundtripSummary::from_values(
|
||||
&initial,
|
||||
&final_results,
|
||||
Some(true),
|
||||
"config.ncl".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(summary.total_fields, 2);
|
||||
assert_eq!(summary.changed_fields, 1);
|
||||
assert_eq!(summary.unchanged_fields, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("short", 10), "short");
|
||||
assert_eq!(truncate("this is a very long string", 10), "this is...");
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user