From 87de90afa9d71e9dff0e430fa5d8188c2bb4e2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Sun, 28 Dec 2025 12:33:37 +0000 Subject: [PATCH] chore: fix load defaults in backends --- crates/typedialog-ai/src/backend.rs | 4 + crates/typedialog-core/src/backends/cli.rs | 4 + crates/typedialog-core/src/backends/mod.rs | 3 + crates/typedialog-core/src/backends/tui.rs | 4 + .../typedialog-core/src/backends/web/mod.rs | 125 +++++- .../src/form_parser/conditions.rs | 3 +- .../src/form_parser/executor.rs | 153 +++---- crates/typedialog-core/src/form_parser/mod.rs | 4 +- .../typedialog-core/src/form_parser/parser.rs | 32 -- crates/typedialog-core/src/nickel/mod.rs | 2 + crates/typedialog-core/src/nickel/summary.rs | 414 ++++++++++++++++++ 11 files changed, 616 insertions(+), 132 deletions(-) create mode 100644 crates/typedialog-core/src/nickel/summary.rs diff --git a/crates/typedialog-ai/src/backend.rs b/crates/typedialog-ai/src/backend.rs index 9d4cc27..b7b11b8 100644 --- a/crates/typedialog-ai/src/backend.rs +++ b/crates/typedialog-ai/src/backend.rs @@ -201,6 +201,10 @@ impl FormBackend for AiBackend { fn name(&self) -> &str { "ai" } + + fn as_any(&self) -> &dyn std::any::Any { + self + } } impl AiBackend { diff --git a/crates/typedialog-core/src/backends/cli.rs b/crates/typedialog-core/src/backends/cli.rs index dd635a4..5241865 100644 --- a/crates/typedialog-core/src/backends/cli.rs +++ b/crates/typedialog-core/src/backends/cli.rs @@ -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(()) diff --git a/crates/typedialog-core/src/backends/mod.rs b/crates/typedialog-core/src/backends/mod.rs index becb739..9ef97e7 100644 --- a/crates/typedialog-core/src/backends/mod.rs +++ b/crates/typedialog-core/src/backends/mod.rs @@ -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 diff --git a/crates/typedialog-core/src/backends/tui.rs b/crates/typedialog-core/src/backends/tui.rs index cc9155a..f0441da 100644 --- a/crates/typedialog-core/src/backends/tui.rs +++ b/crates/typedialog-core/src/backends/tui.rs @@ -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; diff --git a/crates/typedialog-core/src/backends/web/mod.rs b/crates/typedialog-core/src/backends/web/mod.rs index 35c60dd..ff04e01 100644 --- a/crates/typedialog-core/src/backends/web/mod.rs +++ b/crates/typedialog-core/src/backends/web/mod.rs @@ -20,6 +20,14 @@ use crate::form_parser::{DisplayItem, FieldDefinition, FieldType}; /// Type alias for complete form submission channel type CompleteFormChannel = Arc>>>>; +/// Roundtrip context for nickel-roundtrip mode +#[derive(Clone)] +pub struct RoundtripContext { + pub initial_values: HashMap, + 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>>, /// Base directory for lazy loading fragments (FASE 4) base_dir: Arc>>, + /// Roundtrip context (only set in nickel-roundtrip mode) + roundtrip_context: Arc>>, } 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> { + 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>) -> 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#"
- + {}
"#, 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) { diff --git a/crates/typedialog-core/src/form_parser/conditions.rs b/crates/typedialog-core/src/form_parser/conditions.rs index 2639241..c9ed9fd 100644 --- a/crates/typedialog-core/src/form_parser/conditions.rs +++ b/crates/typedialog-core/src/form_parser/conditions.rs @@ -297,7 +297,8 @@ fn extract_numeric(v: &serde_json::Value) -> Option { } /// 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(), diff --git a/crates/typedialog-core/src/form_parser/executor.rs b/crates/typedialog-core/src/form_parser/executor.rs index fa6379a..1601ce2 100644 --- a/crates/typedialog-core/src/form_parser/executor.rs +++ b/crates/typedialog-core/src/form_parser/executor.rs @@ -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 Result> { 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 = 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(); diff --git a/crates/typedialog-core/src/form_parser/mod.rs b/crates/typedialog-core/src/form_parser/mod.rs index 2acf442..1b367df 100644 --- a/crates/typedialog-core/src/form_parser/mod.rs +++ b/crates/typedialog-core/src/form_parser/mod.rs @@ -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}; diff --git a/crates/typedialog-core/src/form_parser/parser.rs b/crates/typedialog-core/src/form_parser/parser.rs index 1934bb7..fb8f118 100644 --- a/crates/typedialog-core/src/form_parser/parser.rs +++ b/crates/typedialog-core/src/form_parser/parser.rs @@ -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> { - 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> { - 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) -} diff --git a/crates/typedialog-core/src/nickel/mod.rs b/crates/typedialog-core/src/nickel/mod.rs index ba564e6..498d054 100644 --- a/crates/typedialog-core/src/nickel/mod.rs +++ b/crates/typedialog-core/src/nickel/mod.rs @@ -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; diff --git a/crates/typedialog-core/src/nickel/summary.rs b/crates/typedialog-core/src/nickel/summary.rs new file mode 100644 index 0000000..788e4a6 --- /dev/null +++ b/crates/typedialog-core/src/nickel/summary.rs @@ -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, + + /// Validation status + pub validation_passed: Option, + + /// 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, + final_results: &HashMap, + validation_passed: Option, + 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 = 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#"✓ PASSED"#, + Some(false) => r#"✗ FAILED"#, + None => r#"N/A"#, + }; + + let changes_html = if self.changed_fields > 0 { + let mut html = String::from("
"); + html.push_str("

📋 What Changed:

"); + html.push_str("
"); + + for change in &self.changes { + if !change.changed { + continue; + } + + html.push_str(&format!( + r#"
+
{}
+
- {}
+
+ {}
+
"#, + html_escape(&change.field_name), + html_escape(&change.old_value), + html_escape(&change.new_value) + )); + } + + html.push_str("
"); + html + } else { + String::from("

No changes made

") + }; + + format!( + r#" + + + Configuration Saved + + + + + +
+
+

✅ Configuration Saved Successfully!

+
+ +
+ 📄 File: + {} +
+ +
+ Validation: + {} +
+ +
+ 📊 Fields Configured: + {} total ({} changed, {} unchanged) +
+ + {} + +
+

💡 Next Steps:

+
    +
  • Review configuration: cat {}
  • +
  • Apply CI tools: ./setup-ci.sh
  • +
  • Re-configure anytime: ./ci-configure.sh
  • +
+
+ +
+ + + +
+ +
+ ⏱️ This window will auto-close in 30 seconds... +
+
+ +"#, + 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 = 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..."); + } +}