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

This commit is contained in:
Jesús Pérez 2025-12-28 12:33:37 +00:00
parent b906168f6d
commit 87de90afa9
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
11 changed files with 616 additions and 132 deletions

View File

@ -201,6 +201,10 @@ impl FormBackend for AiBackend {
fn name(&self) -> &str {
"ai"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl AiBackend {

View File

@ -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(())

View File

@ -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

View File

@ -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;

View File

@ -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(&current_value),
html_escape(placeholder),
html_escape(&type_hint),
if field.required.unwrap_or(false) {

View File

@ -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(),

View File

@ -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();

View File

@ -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};

View File

@ -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)
}

View File

@ -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;

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
#[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...");
}
}