mod analysis;
mod audit;
mod examples;
mod excel_io;
mod functions;
mod prediction;
pub mod results;
mod schema;
mod simulate;
mod update;
mod upgrade;
pub use analysis::{break_even, compare, goal_seek, sensitivity, variance};
pub use audit::audit;
pub use examples::examples;
pub use excel_io::{export, import};
pub use functions::functions;
pub use prediction::{bayesian, bootstrap, decision_tree, real_options, scenarios, tornado};
pub use schema::schema;
pub use simulate::simulate;
pub use update::update;
pub use upgrade::{auto_upgrade_schema, needs_schema_upgrade, upgrade};
pub use analysis::{compare_core, goal_seek_core, sensitivity_core, variance_core};
pub use audit::audit_core;
pub use examples::examples_core;
pub use excel_io::{export_buffer_core, export_core, import_core};
pub use functions::functions_core;
pub use prediction::{
bayesian_core, bootstrap_core, decision_tree_core, real_options_core, scenarios_core,
tornado_core,
};
pub use schema::schema_core;
pub use simulate::simulate_core;
#[cfg(test)]
pub use analysis::{
calculate_with_override, export_variance_to_excel, export_variance_to_yaml, parse_range,
print_variance_table, VarianceResult,
};
#[cfg(test)]
pub use audit::{
build_dependency_tree, extract_references_from_formula, find_variable, print_dependency,
AuditDependency,
};
#[cfg(test)]
pub use upgrade::split_scalars_to_inputs_outputs;
use crate::core::{ArrayCalculator, UnitValidator};
use crate::error::{ForgeError, ForgeResult};
use crate::parser;
use crate::writer;
use colored::Colorize;
use std::path::Path;
use std::path::PathBuf;
#[cfg(not(coverage))]
use notify::RecursiveMode;
#[cfg(not(coverage))]
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
#[cfg(not(coverage))]
use std::sync::mpsc::channel;
#[cfg(not(coverage))]
use std::time::Duration;
#[must_use]
pub fn format_number(n: f64) -> String {
let rounded = (n * 1e6).round() / 1e6;
format!("{rounded:.6}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
pub fn calculate_core(
file: &Path,
dry_run: bool,
scenario: Option<&str>,
) -> ForgeResult<results::CalculationResult> {
let mut model = parser::parse_model(file)?;
if let Some(scenario_name) = scenario {
apply_scenario(&mut model, scenario_name)?;
}
let unit_validator = UnitValidator::new(&model);
let unit_warnings: Vec<String> = unit_validator
.validate()
.iter()
.map(ToString::to_string)
.collect();
let calculator = ArrayCalculator::new(model);
let result = calculator.calculate_all()?;
let mut tables = std::collections::HashMap::new();
for (table_name, table) in &result.tables {
let row_count = table
.columns
.values()
.next()
.map_or(0, |col| col.values.len());
tables.insert(
table_name.clone(),
results::TableSummary {
name: table_name.clone(),
column_count: table.columns.len(),
row_count,
columns: table.columns.keys().cloned().collect(),
},
);
}
let mut scalars = std::collections::HashMap::new();
for (name, var) in &result.scalars {
scalars.insert(name.clone(), var.value);
}
let file_updated = if dry_run {
false
} else {
writer::write_calculated_results(file, &result)?
};
Ok(results::CalculationResult {
tables,
scalars,
unit_warnings,
file_updated,
dry_run,
})
}
pub fn calculate(
file: &Path,
dry_run: bool,
verbose: bool,
scenario: Option<&str>,
) -> ForgeResult<()> {
println!("{}", "🔥 Forge - Calculating formulas".bold().green());
println!(" File: {}", file.display());
if let Some(s) = scenario {
println!(" Scenario: {}", s.bright_yellow().bold());
}
println!();
if dry_run {
println!(
"{}",
"📋 DRY RUN MODE - No changes will be written\n".yellow()
);
}
if verbose {
println!("{}", "📖 Parsing YAML file...".cyan());
}
let mut model = parser::parse_model(file)?;
if verbose {
println!(
" Found {} tables, {} scalars",
model.tables.len(),
model.scalars.len()
);
if !model.scenarios.is_empty() {
println!(
" Found {} scenarios: {:?}",
model.scenarios.len(),
model.scenario_names()
);
}
println!();
}
if let Some(scenario_name) = scenario {
apply_scenario(&mut model, scenario_name)?;
if verbose {
println!("{}", format!("📊 Applied scenario: {scenario_name}").cyan());
}
}
let unit_validator = UnitValidator::new(&model);
let unit_warnings = unit_validator.validate();
if !unit_warnings.is_empty() {
println!("{}", "⚠️ Unit Consistency Warnings:".yellow().bold());
for warning in &unit_warnings {
println!(" {}", warning.to_string().yellow());
}
println!();
}
if verbose {
println!("{}", "🧮 Calculating tables and scalars...".cyan());
}
let calculator = ArrayCalculator::new(model);
let result = calculator.calculate_all()?;
println!("{}", "✅ Calculation Results:".bold().green());
for (table_name, table) in &result.tables {
println!(" 📊 Table: {}", table_name.bright_blue().bold());
for (col_name, column) in &table.columns {
println!(" {} ({} rows)", col_name.cyan(), column.values.len());
}
}
if !result.scalars.is_empty() {
println!("\n 📐 Scalars:");
for (name, var) in &result.scalars {
if let Some(value) = var.value {
println!(
" {} = {}",
name.bright_blue(),
format!("{value}").bold()
);
}
}
}
println!();
if dry_run {
println!("{}", "📋 Dry run complete - no changes written".yellow());
} else {
let wrote = writer::write_calculated_results(file, &result)?;
if wrote {
println!(
"{}",
format!("💾 Results written to {}", file.display())
.bold()
.green()
);
println!(
"{}",
format!(" Backup saved to {}.bak", file.display()).dimmed()
);
} else {
println!(
"{}",
"⚠️ Multi-document YAML - write-back not supported yet".yellow()
);
println!(
"{}",
" Results displayed above. Split into separate files to persist.".dimmed()
);
}
}
Ok(())
}
pub fn validate_core(file: &Path) -> ForgeResult<results::ValidationResult> {
const TOLERANCE: f64 = 0.0001;
let model = parser::parse_model(file)?;
let table_count = model.tables.len();
let scalar_count = model.scalars.len();
let calculator = ArrayCalculator::new(model.clone());
let calculated = calculator.calculate_all()?;
let mut mismatches = Vec::new();
for (var_name, var) in &calculated.scalars {
if let Some(calculated_value) = var.value {
if let Some(original) = model.scalars.get(var_name) {
if let Some(current_value) = original.value {
let diff = (current_value - calculated_value).abs();
if diff > TOLERANCE {
mismatches.push(results::ValidationMismatch {
name: var_name.clone(),
current_value,
expected_value: calculated_value,
diff,
});
}
}
}
}
}
let scalars_valid = mismatches.is_empty();
Ok(results::ValidationResult {
tables_valid: true,
scalars_valid,
table_count,
scalar_count,
mismatches,
})
}
pub fn validate(files: &[PathBuf]) -> ForgeResult<()> {
let file_count = files.len();
let is_batch = file_count > 1;
if is_batch {
println!(
"{}",
format!("✅ Validating {file_count} files").bold().green()
);
println!();
}
let mut all_passed = true;
let mut failed_files: Vec<String> = Vec::new();
for file in files {
if is_batch {
println!("{}", format!("─── {} ───", file.display()).cyan());
} else {
println!("{}", "✅ Validating model".bold().green());
println!(" File: {}\n", file.display());
}
match validate_single_file(file) {
Ok(()) => {
if is_batch {
println!("{}", format!(" ✅ {} - OK", file.display()).green());
println!();
}
},
Err(e) => {
if !is_batch {
return Err(e);
}
all_passed = false;
failed_files.push(format!("{}: {}", file.display(), e));
println!("{}", format!(" ❌ {} - FAILED", file.display()).red());
println!(" {}", e.to_string().red());
println!();
},
}
}
if is_batch {
println!("{}", "─".repeat(50));
let passed = file_count - failed_files.len();
println!(
" {} passed, {} failed out of {} files",
passed.to_string().green(),
failed_files.len().to_string().red(),
file_count
);
}
if all_passed {
Ok(())
} else {
Err(ForgeError::Validation(format!(
"{} file(s) failed validation",
failed_files.len()
)))
}
}
fn validate_single_file(file: &std::path::Path) -> ForgeResult<()> {
const TOLERANCE: f64 = 0.0001;
let model = parser::parse_model(file)?;
if model.tables.is_empty() && model.scalars.is_empty() {
println!("{}", "⚠️ No tables or scalars found in YAML file".yellow());
return Ok(());
}
println!(
" Found {} tables, {} scalars",
model.tables.len(),
model.scalars.len()
);
let calculator = ArrayCalculator::new(model.clone());
let calculated = match calculator.calculate_all() {
Ok(vals) => vals,
Err(e) => {
println!(
"\n{}",
format!("❌ Formula validation failed: {e}").bold().red()
);
return Err(e);
},
};
let mut mismatches = Vec::new();
for (var_name, var) in &calculated.scalars {
if let Some(calculated_value) = var.value {
if let Some(original) = model.scalars.get(var_name) {
if let Some(current_value) = original.value {
let diff = (current_value - calculated_value).abs();
if diff > TOLERANCE {
mismatches.push((var_name.clone(), current_value, calculated_value, diff));
}
}
}
}
}
println!();
if mismatches.is_empty() {
println!("{}", "✅ All tables are valid!".bold().green());
println!(
"{}",
"✅ All scalar values match their formulas!".bold().green()
);
Ok(())
} else {
println!(
"{}",
format!("❌ Found {} value mismatches!", mismatches.len())
.bold()
.red()
);
println!("{}", " File needs recalculation!\n".yellow());
for (name, current, expected, diff) in &mismatches {
println!(" {}", name.bright_blue().bold());
println!(" Current: {}", format_number(*current).clone().red());
println!(
" Expected: {}",
format_number(*expected).clone().green()
);
println!(" Diff: {}", format!("{diff:.6}").yellow());
println!();
}
println!(
"{}",
"💡 Run 'forge calculate' to update values".bold().yellow()
);
Err(crate::error::ForgeError::Validation(
"Values do not match formulas - file needs recalculation".to_string(),
))
}
}
#[cfg(not(coverage))]
pub fn watch(file: &Path, validate_only: bool, verbose: bool) -> ForgeResult<()> {
println!("{}", "👁️ Forge - Watch Mode".bold().green());
println!(" Watching: {}", file.display());
println!(
" Mode: {}",
if validate_only {
"validate only"
} else {
"calculate"
}
);
println!(" Press {} to stop\n", "Ctrl+C".bold().yellow());
if !file.exists() {
return Err(ForgeError::Validation(format!(
"File not found: {}",
file.display()
)));
}
let canonical_path = file.canonicalize().map_err(ForgeError::Io)?;
let parent_dir = canonical_path
.parent()
.ok_or_else(|| ForgeError::Validation("Cannot determine parent directory".to_string()))?;
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_millis(200), tx)
.map_err(|e| ForgeError::Validation(format!("Failed to create file watcher: {e}")))?;
debouncer
.watcher()
.watch(parent_dir, RecursiveMode::NonRecursive)
.map_err(|e| ForgeError::Validation(format!("Failed to watch directory: {e}")))?;
if verbose {
println!(
" {} {}",
"Watching directory:".cyan(),
parent_dir.display()
);
}
println!("{}", "🔄 Initial run...".cyan());
run_watch_action(file, validate_only, verbose);
println!();
loop {
match rx.recv() {
Ok(Ok(events)) => {
let relevant = events.iter().any(|event| {
if event.kind != DebouncedEventKind::Any {
return false;
}
if let Ok(event_canonical) = event.path.canonicalize() {
if event_canonical == canonical_path {
return true;
}
}
if let Some(filename) = event.path.file_name() {
if let Some(our_filename) = canonical_path.file_name() {
if filename == our_filename {
return true;
}
}
if let Some(ext) = event.path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml") {
return true;
}
}
}
false
});
if relevant {
if verbose {
print!("\x1B[2J\x1B[1;1H"); }
println!(
"\n{} {}",
"🔄 Change detected at".cyan(),
chrono_lite_timestamp().cyan()
);
run_watch_action(file, validate_only, verbose);
println!();
}
},
Ok(Err(error)) => {
eprintln!("{} Watch error: {}", "❌".red(), error);
},
Err(e) => {
eprintln!("{} Channel error: {}", "❌".red(), e);
break;
},
}
}
Ok(())
}
#[cfg(coverage)]
pub fn watch(file: &Path, _validate_only: bool, _verbose: bool) -> ForgeResult<()> {
if !file.exists() {
return Err(ForgeError::Validation(format!(
"File not found: {}",
file.display()
)));
}
Ok(())
}
#[cfg(any(not(coverage), test))]
fn chrono_lite_timestamp() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{hours:02}:{minutes:02}:{seconds:02} UTC")
}
#[cfg(any(not(coverage), test))]
fn run_watch_action(file: &Path, validate_only: bool, verbose: bool) {
if validate_only {
match validate_internal(file, verbose) {
Ok(()) => println!("{}", "✅ Validation passed".bold().green()),
Err(e) => println!("{} {}", "❌ Validation failed:".bold().red(), e),
}
} else {
match calculate_internal(file, verbose) {
Ok(()) => println!("{}", "✅ Calculation complete".bold().green()),
Err(e) => println!("{} {}", "❌ Calculation failed:".bold().red(), e),
}
}
}
#[cfg(any(not(coverage), test))]
fn validate_internal(file: &Path, verbose: bool) -> ForgeResult<()> {
const TOLERANCE: f64 = 0.0001;
let model = parser::parse_model(file)?;
if verbose {
println!(
" Found {} tables, {} scalars",
model.tables.len(),
model.scalars.len()
);
}
let calculator = ArrayCalculator::new(model.clone());
let calculated = calculator.calculate_all()?;
let mut mismatches = Vec::new();
for (var_name, var) in &calculated.scalars {
if let Some(calculated_value) = var.value {
if let Some(original) = model.scalars.get(var_name) {
if let Some(current_value) = original.value {
let diff = (current_value - calculated_value).abs();
if diff > TOLERANCE {
mismatches.push((var_name.clone(), current_value, calculated_value));
}
}
}
}
}
if !mismatches.is_empty() {
let msg = mismatches
.iter()
.map(|(name, current, expected)| {
format!(" {name} current={current} expected={expected}")
})
.collect::<Vec<_>>()
.join("\n");
return Err(ForgeError::Validation(format!(
"{} value mismatches:\n{}",
mismatches.len(),
msg
)));
}
Ok(())
}
#[cfg(any(not(coverage), test))]
fn calculate_internal(file: &Path, verbose: bool) -> ForgeResult<()> {
let model = parser::parse_model(file)?;
if verbose {
println!(
" Found {} tables, {} scalars",
model.tables.len(),
model.scalars.len()
);
}
let calculator = ArrayCalculator::new(model);
let result = calculator.calculate_all()?;
for (table_name, table) in &result.tables {
println!(
" 📊 {} ({} columns)",
table_name.bright_blue(),
table.columns.len()
);
}
if !result.scalars.is_empty() && verbose {
println!(" 📐 {} scalars calculated", result.scalars.len());
}
Ok(())
}
pub fn apply_scenario(
model: &mut crate::types::ParsedModel,
scenario_name: &str,
) -> ForgeResult<()> {
let scenario = model.scenarios.get(scenario_name).ok_or_else(|| {
let available: Vec<_> = model.scenarios.keys().collect();
ForgeError::Validation(format!(
"Scenario '{scenario_name}' not found. Available scenarios: {available:?}"
))
})?;
let overrides = scenario.overrides.clone();
for (var_name, override_value) in &overrides {
let resolved = model
.resolve_scalar_name(var_name)
.map_err(ForgeError::Validation)?;
if let Some(scalar) = model.scalars.get_mut(&resolved) {
scalar.value = Some(*override_value);
scalar.formula = None;
} else {
model.scalars.insert(
resolved.clone(),
crate::types::Variable::new(resolved, Some(*override_value), None),
);
}
}
Ok(())
}