//! Build-time substitution of business assumptions into markdown. //! //! Loads a TOML "source of truth" file, computes a registry of derived values, //! validates internal consistency, and substitutes `{{ dotted.path }}` markers //! in markdown before rendering. //! //! The intended pipeline is: //! //! ```ignore //! let assumptions = Assumptions::load("assumptions.toml")?; //! assumptions.validate()?; //! let resolved = assumptions.substitute(&markdown)?; //! let html = docengine::render_permissive(&resolved); //! ``` //! //! Substitution runs on raw markdown before parsing so values may appear //! anywhere: prose, code spans, table cells, link text. use std::collections::HashMap; use std::fmt; use std::fs; use std::path::Path; use serde::Deserialize; // ─── Public types ───────────────────────────────────────────────────────── /// Top-level errors returned by [`Assumptions::load`] / [`substitute`]. #[derive(Debug)] pub enum AssumptionsError { Io(std::io::Error), Parse(toml::de::Error), Validation(Vec), Substitution { unresolved: Vec }, } impl fmt::Display for AssumptionsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "I/O error: {e}"), Self::Parse(e) => write!(f, "TOML parse error: {e}"), Self::Validation(failures) => { writeln!(f, "validation failed ({} rule(s)):", failures.len())?; for rule in failures { writeln!(f, " - {rule}")?; } Ok(()) } Self::Substitution { unresolved } => { write!(f, "unresolved placeholders: {}", unresolved.join(", ")) } } } } impl std::error::Error for AssumptionsError {} impl From for AssumptionsError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } impl From for AssumptionsError { fn from(e: toml::de::Error) -> Self { Self::Parse(e) } } /// A leaf value from the assumptions table or derived registry. #[derive(Debug, Clone, PartialEq)] pub enum LookupValue { Int(i64), Float(f64), String(String), } impl fmt::Display for LookupValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Int(n) => write!(f, "{n}"), Self::Float(x) => f.write_str(&format_float(*x)), Self::String(s) => f.write_str(s), } } } /// Format a float with up to 4 decimal places, trimming trailing zeros. /// /// `22.0 -> "22"`, `0.59 -> "0.59"`, `0.0367 -> "0.0367"`, `61960.0 -> "61960"`. fn format_float(x: f64) -> String { let s = format!("{x:.4}"); let trimmed = s.trim_end_matches('0').trim_end_matches('.'); trimmed.to_string() } /// Loaded + validated business assumptions plus a flat lookup table and a /// filter registry. pub struct Assumptions { typed: Typed, lookup: HashMap, filters: HashMap>, } impl Assumptions { /// Load assumptions from a TOML file. pub fn load>(path: P) -> Result { let text = fs::read_to_string(path)?; Self::parse(&text) } /// Parse assumptions from a TOML string. pub fn parse(text: &str) -> Result { let value: toml::Value = toml::from_str(text)?; let typed: Typed = value.clone().try_into()?; let mut lookup = HashMap::new(); walk_value(&value, String::new(), &mut lookup); insert_derived(&typed, &mut lookup); let mut filters: HashMap> = HashMap::new(); crate::filters::register_builtins(&mut filters); Ok(Self { typed, lookup, filters }) } /// Register a custom filter. Overrides any built-in or previously /// registered filter with the same name. /// /// ```ignore /// let a = Assumptions::load(path)? /// .with_filter("k", |v, _args| { /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?; /// Ok(LookupValue::String(format!("{:.0}K", n / 1000.0))) /// }); /// ``` pub fn with_filter(mut self, name: impl Into, filter: impl crate::filters::Filter + 'static) -> Self { self.filters.insert(name.into(), Box::new(filter)); self } /// Run all consistency checks. Returns `Err` listing every failed rule. pub fn validate(&self) -> Result<(), AssumptionsError> { let mut failures = Vec::new(); let t = &self.typed; // Typo guard on fixed costs. if !(100.0 < t.expenses.f_monthly && t.expenses.f_monthly < 10_000.0) { failures.push(format!( "expenses.F_monthly = {} is outside (100, 10000)", t.expenses.f_monthly )); } // Tier mix must sum to 1.0. let mix = &t.tier_mix.assumed; let mix_sum = mix.basic_pct + mix.small_files_pct + mix.big_files_pct + mix.everything_pct; if (mix_sum - 1.0).abs() > 1e-6 { failures.push(format!("tier_mix.assumed sums to {mix_sum}, expected 1.0")); } // Surplus split must sum to 1.0. let split_sum = t.reserve.surplus_split_reserve + t.reserve.surplus_split_earnback; if (split_sum - 1.0).abs() > 1e-6 { failures.push(format!( "reserve.surplus_split_{{reserve,earnback}} sums to {split_sum}, expected 1.0" )); } // Rho bounds. if !(0.0 < t.reserve.rho_annual && t.reserve.rho_annual <= 1.0) { failures.push(format!( "reserve.rho_annual = {} is outside (0, 1]", t.reserve.rho_annual )); } if !(0.0 < t.reserve.rho_incident && t.reserve.rho_incident <= 1.0) { failures.push(format!( "reserve.rho_incident = {} is outside (0, 1]", t.reserve.rho_incident )); } if t.reserve.rho_incident > t.reserve.rho_annual { failures.push(format!( "reserve.rho_incident ({}) > reserve.rho_annual ({})", t.reserve.rho_incident, t.reserve.rho_annual )); } // Founding ≤ standard for every tier. let f = &t.tiers.founding; let s = &t.tiers.standard; for (name, fv, sv) in [ ("basic", f.basic, s.basic), ("small_files", f.small_files, s.small_files), ("big_files", f.big_files, s.big_files), ("everything", f.everything, s.everything), ] { if fv > sv { failures.push(format!( "tiers.founding.{name} ({fv}) > tiers.standard.{name} ({sv})" )); } } // Cohort caps positive. if t.cohort.cap_count <= 0 { failures.push(format!("cohort.cap_count = {} must be > 0", t.cohort.cap_count)); } if t.cohort.cap_months <= 0 { failures.push(format!( "cohort.cap_months = {} must be > 0", t.cohort.cap_months )); } if failures.is_empty() { Ok(()) } else { Err(AssumptionsError::Validation(failures)) } } /// Substitute `{{ dotted.path }}` placeholders in markdown. /// /// Returns `Err(Substitution)` listing every key that could not be /// resolved. The output is the markdown with all resolved keys replaced; /// unresolved keys are left in place when an error is returned, so callers /// can grep for them. pub fn substitute(&self, markdown: &str) -> Result { // Matches `{{ … }}` non-greedily — the inner body may contain spaces, // pipes, parens, and quoted strings (e.g. `{{ x | money("$") }}`). let re = regex_lite::Regex::new(r"\{\{(.*?)\}\}").expect("static regex"); // Skip matches inside inline code spans and fenced code blocks so that // documentation showing literal `{{ … }}` template syntax (e.g. Tauri // updater URL patterns) is preserved verbatim. let code_ranges = crate::code_spans::code_span_ranges(markdown); let in_code = |start: usize| { code_ranges .iter() .any(|&(s, e)| start >= s && start < e) }; let mut unresolved: Vec = Vec::new(); let mut errors: Vec = Vec::new(); let mut out = String::with_capacity(markdown.len()); let mut last = 0; for m in re.find_iter(markdown) { out.push_str(&markdown[last..m.start()]); last = m.end(); if in_code(m.start()) { out.push_str(m.as_str()); continue; } let body = re.captures(m.as_str()).unwrap().get(1).unwrap().as_str(); match self.resolve(body) { Ok(Some(v)) => out.push_str(&v.to_string()), Ok(None) => { // Path not found in lookup. Preserve marker; flag for caller. unresolved.push(body.trim().to_string()); out.push_str(m.as_str()); } Err(e) => { errors.push(format!("`{body}`: {e}")); out.push_str(m.as_str()); } } } out.push_str(&markdown[last..]); if !errors.is_empty() { return Err(AssumptionsError::Substitution { unresolved: errors, }); } if !unresolved.is_empty() { unresolved.sort(); unresolved.dedup(); return Err(AssumptionsError::Substitution { unresolved }); } Ok(out) } /// Resolve a single `{{ body }}` expression. Returns: /// - `Ok(Some(v))` when the path resolved and all filters applied cleanly. /// - `Ok(None)` when the path is missing from the lookup table. /// - `Err(_)` for parse errors, unknown filters, or filter failures. fn resolve(&self, body: &str) -> Result, String> { let expr = crate::filters::parse_expr(body).map_err(|e| e.to_string())?; let Some(initial) = self.lookup.get(expr.path).cloned() else { return Ok(None); }; let mut value = initial; for call in &expr.filters { let filter = self .filters .get(call.name) .ok_or_else(|| format!("unknown filter `{}`", call.name))?; value = filter .apply(value, &call.args) .map_err(|e| e.to_string())?; } Ok(Some(value)) } /// Look up a single key. Useful for testing and for programmatic callers /// that don't want to go through markdown substitution. pub fn get(&self, key: &str) -> Option<&LookupValue> { self.lookup.get(key) } /// Iterate over every available key (raw + derived) in arbitrary order. pub fn keys(&self) -> impl Iterator { self.lookup.keys().map(String::as_str) } } // ─── Typed mirror of assumptions.toml ───────────────────────────────────── // // Only the fields needed for validation and derived values. Unknown fields // (e.g. `[expenses.lines]`, `[stripe.connect_express]`) are silently ignored // by serde but still reach the lookup table through `walk_value`. #[derive(Debug, Deserialize)] struct Typed { expenses: TExpenses, stripe: TStripe, tiers: TTiers, tier_mix: TTierMix, reserve: TReserve, cohort: TCohort, creator_marginal: TCreatorMarginal, annual_discount: TAnnualDiscount, } #[derive(Debug, Deserialize)] struct TExpenses { #[serde(rename = "F_monthly")] f_monthly: f64, } #[derive(Debug, Deserialize)] struct TStripe { percent: f64, fixed: f64, dispute_fee: f64, } #[derive(Debug, Deserialize)] struct TTiers { founding: TTierPrices, standard: TTierPrices, } #[derive(Debug, Deserialize)] struct TTierPrices { basic: f64, small_files: f64, big_files: f64, everything: f64, } #[derive(Debug, Deserialize)] struct TTierMix { assumed: TMixWeights, } #[derive(Debug, Deserialize)] struct TMixWeights { basic_pct: f64, small_files_pct: f64, big_files_pct: f64, everything_pct: f64, } #[derive(Debug, Deserialize)] struct TReserve { #[serde(rename = "T_fixed_months")] t_fixed_months: f64, #[serde(rename = "S_legal")] s_legal: f64, #[serde(rename = "S_shock")] s_shock: f64, #[serde(rename = "R_opp")] r_opp: f64, rho_annual: f64, rho_incident: f64, surplus_split_reserve: f64, surplus_split_earnback: f64, } #[derive(Debug, Deserialize)] struct TCohort { cap_count: i64, cap_months: i64, } #[derive(Debug, Deserialize)] struct TAnnualDiscount { multiplier: f64, } #[derive(Debug, Deserialize)] struct TCreatorMarginal { storage_basic_gb: f64, storage_small_files_gb: f64, storage_big_files_gb: f64, storage_everything_gb: f64, storage_cost_per_gb_per_month: f64, chargeback_rate_tier_subs: f64, } // ─── Walking + derived ──────────────────────────────────────────────────── fn walk_value(value: &toml::Value, prefix: String, out: &mut HashMap) { match value { toml::Value::Table(table) => { for (k, v) in table { let key = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; walk_value(v, key, out); } } toml::Value::Integer(n) => { out.insert(prefix, LookupValue::Int(*n)); } toml::Value::Float(x) => { out.insert(prefix, LookupValue::Float(*x)); } toml::Value::String(s) => { out.insert(prefix, LookupValue::String(s.clone())); } // Bools, arrays, datetimes: not substitutable. toml::Value::Boolean(_) | toml::Value::Array(_) | toml::Value::Datetime(_) => {} } } fn insert_derived(t: &Typed, out: &mut HashMap) { let mut put = |k: &str, v: f64| { out.insert(format!("derived.{k}"), LookupValue::Float(v)); }; let r_cap = t.reserve.t_fixed_months * t.expenses.f_monthly + t.reserve.s_legal + t.reserve.s_shock; put("R_cap", r_cap); // ARPU per rate class. let mix = &t.tier_mix.assumed; let arpu_founding = mix.basic_pct * t.tiers.founding.basic + mix.small_files_pct * t.tiers.founding.small_files + mix.big_files_pct * t.tiers.founding.big_files + mix.everything_pct * t.tiers.founding.everything; let arpu_standard = mix.basic_pct * t.tiers.standard.basic + mix.small_files_pct * t.tiers.standard.small_files + mix.big_files_pct * t.tiers.standard.big_files + mix.everything_pct * t.tiers.standard.everything; put("ARPU_founding", arpu_founding); put("ARPU_standard", arpu_standard); // Stripe fee per tier price (creator-facing examples — these are what the // creator pays Stripe on their fan transactions, NOT what MNW pays Stripe). let stripe_fee = |amt: f64| t.stripe.percent * amt + t.stripe.fixed; put("stripe_fee_basic_std", stripe_fee(t.tiers.standard.basic)); put("stripe_fee_small_std", stripe_fee(t.tiers.standard.small_files)); put("stripe_fee_big_std", stripe_fee(t.tiers.standard.big_files)); put("stripe_fee_ev_std", stripe_fee(t.tiers.standard.everything)); // Annual prices per tier (monthly × 12 × annual_discount.multiplier, rounded // to nearest dollar). Substituted into docs as `${{ derived.annual_*_* }}` // so a price change auto-propagates. let yr = |monthly: f64| (monthly * 12.0 * t.annual_discount.multiplier).round(); put("annual_founding_basic", yr(t.tiers.founding.basic)); put("annual_founding_small_files", yr(t.tiers.founding.small_files)); put("annual_founding_big_files", yr(t.tiers.founding.big_files)); put("annual_founding_everything", yr(t.tiers.founding.everything)); put("annual_standard_basic", yr(t.tiers.standard.basic)); put("annual_standard_small_files", yr(t.tiers.standard.small_files)); put("annual_standard_big_files", yr(t.tiers.standard.big_files)); put("annual_standard_everything", yr(t.tiers.standard.everything)); // ── Marginal cost per creator/month, broken down by component ── // // What MNW pays per active creator on top of fixed costs F: // // storage : weighted GB × $/GB/month — Hetzner object storage. // stripe_sub : Stripe processing fee on the creator's tier subscription // (creator→MNW). Weighted across the tier mix. Stripe fees // on fan→creator transactions are $0 to MNW (Connect Std). // chargeback : Expected dispute fee per sub/month (small but real). // // Components are emitted individually so docs can show a breakdown table. // `marginal_avg_{standard,founding}` is the sum per rate class. // // NOT modeled (deliberately): egress (at current scale, within Hetzner's // 20 TB/server free allowance), support time (unmeasured pre-launch — see // A26 in assumptions.md). let m = &t.creator_marginal; let marginal_storage = (mix.basic_pct * m.storage_basic_gb + mix.small_files_pct * m.storage_small_files_gb + mix.big_files_pct * m.storage_big_files_gb + mix.everything_pct * m.storage_everything_gb) * m.storage_cost_per_gb_per_month; put("marginal_storage", marginal_storage); let weighted_stripe_sub = |tiers: &TTierPrices| { mix.basic_pct * stripe_fee(tiers.basic) + mix.small_files_pct * stripe_fee(tiers.small_files) + mix.big_files_pct * stripe_fee(tiers.big_files) + mix.everything_pct * stripe_fee(tiers.everything) }; let marginal_stripe_standard = weighted_stripe_sub(&t.tiers.standard); let marginal_stripe_founding = weighted_stripe_sub(&t.tiers.founding); put("marginal_stripe_standard", marginal_stripe_standard); put("marginal_stripe_founding", marginal_stripe_founding); // Chargeback expected value: rate × dispute fee. The lost-revenue portion // (disputed charge amount) is treated as a refund of the original sub // payment, not a separate cost — it flows through ARPU naturally if rates // are accurate. Only the $15 dispute fee is incremental. let marginal_chargeback = m.chargeback_rate_tier_subs * t.stripe.dispute_fee; put("marginal_chargeback", marginal_chargeback); let marginal_avg_standard = marginal_storage + marginal_stripe_standard + marginal_chargeback; let marginal_avg_founding = marginal_storage + marginal_stripe_founding + marginal_chargeback; put("marginal_avg_standard", marginal_avg_standard); put("marginal_avg_founding", marginal_avg_founding); // Back-compat alias for the previous single-value marginal. put("marginal_avg", marginal_avg_standard); // Break-even creator counts (per rate class). let break_even_standard = t.expenses.f_monthly / (arpu_standard - marginal_avg_standard); let break_even_founding = t.expenses.f_monthly / (arpu_founding - marginal_avg_founding); put("break_even_standard", break_even_standard); put("break_even_founding", break_even_founding); // Surplus at representative cohort sizes (per rate class, using the matching marginal). let surplus = |n: f64, arpu: f64, marg: f64| n * (arpu - marg) - t.expenses.f_monthly; put("surplus_100_standard", surplus(100.0, arpu_standard, marginal_avg_standard)); put("surplus_500_standard", surplus(500.0, arpu_standard, marginal_avg_standard)); put("surplus_100_founding", surplus(100.0, arpu_founding, marginal_avg_founding)); put("surplus_500_founding", surplus(500.0, arpu_founding, marginal_avg_founding)); // Fill-time in months to reach R_cap + R_opp at representative cohort sizes // under the standard rate. let target = r_cap + t.reserve.r_opp; let fill_time = |n: f64| target / surplus(n, arpu_standard, marginal_avg_standard); put("fill_time_100", fill_time(100.0)); put("fill_time_500", fill_time(500.0)); } // ─── Tests ──────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // Canonical assumptions.toml ships with the MNW repo at // server/docs/business/assumptions.toml. include_str! resolves relative to // this source file (MNW/shared/docengine/src/), so we walk up to MNW/ then // into server/docs/business/. const FIXTURE: &str = include_str!( "../../../server/docs/business/assumptions.toml" ); fn loaded() -> Assumptions { Assumptions::parse(FIXTURE).expect("fixture parses") } #[test] fn fixture_loads_and_validates() { let a = loaded(); a.validate().expect("fixture validates"); } #[test] fn raw_lookup_returns_int_and_float_variants() { let a = loaded(); assert_eq!(a.get("expenses.F_monthly"), Some(&LookupValue::Int(580))); assert_eq!(a.get("stripe.percent"), Some(&LookupValue::Float(0.029))); assert_eq!( a.get("cohort.lock_duration"), Some(&LookupValue::String("lifetime".into())) ); } #[test] fn derived_values_match_worked_examples() { let a = loaded(); // R_cap = 12 · 580 + 50000 + 5000 = 61960 let r_cap = match a.get("derived.R_cap").unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float, got {v:?}"), }; assert!((r_cap - 61960.0).abs() < 1e-6, "R_cap = {r_cap}"); // ARPU_standard = 0.4·16 + 0.3·24 + 0.2·36 + 0.1·60 = 26.80 let arpu = match a.get("derived.ARPU_standard").unwrap() { LookupValue::Float(x) => *x, v => panic!("{v:?}"), }; assert!((arpu - 26.80).abs() < 1e-9, "ARPU_standard = {arpu}"); // stripe_fee_basic_std = 0.029 · 16 + 0.30 = 0.764 let fee = match a.get("derived.stripe_fee_basic_std").unwrap() { LookupValue::Float(x) => *x, v => panic!("{v:?}"), }; assert!((fee - 0.764).abs() < 1e-9, "stripe_fee_basic_std = {fee}"); } #[test] fn substitute_replaces_known_keys() { let a = loaded(); let out = a .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.") .unwrap(); assert_eq!(out, "Fixed monthly costs are $580."); } #[test] fn substitute_replaces_derived_keys() { let a = loaded(); let out = a.substitute("R_cap = ${{ derived.R_cap }}").unwrap(); assert_eq!(out, "R_cap = $61960"); } #[test] fn substitute_handles_whitespace_in_markers() { let a = loaded(); let out = a .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}") .unwrap(); assert_eq!(out, "a=580 b=580"); } #[test] fn marginal_cost_components_decomposition() { let a = loaded(); let get_f = |k: &str| match a.get(k).unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float at {k}, got {v:?}"), }; // Storage: weighted GB × $/GB/mo. 5.64 GB × 0.0065 = 0.03666 let storage = get_f("derived.marginal_storage"); assert!((storage - 0.03666).abs() < 1e-4, "storage = {storage}"); // Stripe fee on creator subs at standard rates (weighted by tier mix): // 0.4·0.764 + 0.3·0.996 + 0.2·1.344 + 0.1·2.040 = 1.0772 let stripe = get_f("derived.marginal_stripe_standard"); assert!((stripe - 1.0772).abs() < 1e-4, "stripe = {stripe}"); // Chargeback EV: 0.001 × $15 dispute = $0.015 let chargeback = get_f("derived.marginal_chargeback"); assert!((chargeback - 0.015).abs() < 1e-9, "chargeback = {chargeback}"); // marginal_avg_standard = sum let avg = get_f("derived.marginal_avg_standard"); assert!((avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}"); // Founding Stripe fee is lower (lower tier prices, less % component): // 0.4·0.532 + 0.3·0.648 + 0.2·0.822 + 0.1·1.170 = 0.6886 let stripe_f = get_f("derived.marginal_stripe_founding"); assert!((stripe_f - 0.6886).abs() < 1e-4, "stripe_f = {stripe_f}"); } #[test] fn derived_annual_prices_match_published_values() { let a = loaded(); let get_f = |k: &str| match a.get(k).unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float at {k}, got {v:?}"), }; // Founder: $8/$12/$18/$30 × 12 × 0.9 → rounded. assert_eq!(get_f("derived.annual_founding_basic"), 86.0); assert_eq!(get_f("derived.annual_founding_small_files"), 130.0); assert_eq!(get_f("derived.annual_founding_big_files"), 194.0); assert_eq!(get_f("derived.annual_founding_everything"), 324.0); // Standard: $16/$24/$36/$60 × 12 × 0.9 → rounded. assert_eq!(get_f("derived.annual_standard_basic"), 173.0); assert_eq!(get_f("derived.annual_standard_small_files"), 259.0); assert_eq!(get_f("derived.annual_standard_big_files"), 389.0); assert_eq!(get_f("derived.annual_standard_everything"), 648.0); } #[test] fn substitute_applies_ceil_filter_to_derived_value() { let a = loaded(); let out = a .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.") .unwrap(); // break_even_standard ≈ 22.6 → ceil → 23 (with full marginal model at // standard $16/$24/$36/$60: 580 / (26.80 − 1.128) ≈ 22.6). assert_eq!(out, "Break-even at ~23 creators."); } #[test] fn substitute_applies_percent_filter() { let a = loaded(); let out = a.substitute("Stripe charges {{ stripe.percent | percent }}.").unwrap(); assert_eq!(out, "Stripe charges 2.9%."); } #[test] fn substitute_applies_money_filter() { let a = loaded(); let out = a.substitute("Flat fee: {{ stripe.fixed | money }}.").unwrap(); assert_eq!(out, "Flat fee: $0.30."); } #[test] fn substitute_chains_filters() { let a = loaded(); let out = a .substitute("{{ derived.break_even_standard | round(1) }}") .unwrap(); // 580 / (26.80 − ~1.128) ≈ 22.6 at canonical $16/$24/$36/$60. assert_eq!(out, "22.6"); } #[test] fn substitute_consumer_can_register_custom_filter() { // Closure-based filter: format thousands as "Nk". let a = loaded().with_filter( "kilo", |v: LookupValue, _args: &[crate::filters::FilterArg]| { let n = v.as_f64().ok_or_else(|| { crate::filters::FilterError::type_error("kilo", &v) })?; Ok(LookupValue::String(format!("{:.1}k", n / 1000.0))) }, ); let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap(); assert_eq!(out, "Cap: 62.0k"); } #[test] fn substitute_unknown_filter_reports_error() { let a = loaded(); let err = a .substitute("{{ expenses.F_monthly | nope }}") .unwrap_err(); match err { AssumptionsError::Substitution { unresolved } => { assert!(unresolved.iter().any(|m| m.contains("unknown filter")), "{unresolved:?}"); } other => panic!("{other:?}"), } } #[test] fn substitute_filter_type_mismatch_reports_error() { let a = loaded(); let err = a .substitute("{{ cohort.lock_duration | money }}") .unwrap_err(); assert!(matches!(err, AssumptionsError::Substitution { .. })); } #[test] fn substitute_skips_inline_code() { let a = loaded(); let out = a .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.") .unwrap(); assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`."); } #[test] fn substitute_skips_fenced_code_block() { let a = loaded(); let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n"; let out = a.substitute(input).unwrap(); assert!(out.contains("Value: 580")); assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}"); } #[test] fn substitute_reports_unresolved_keys() { let a = loaded(); let err = a .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}") .unwrap_err(); match err { AssumptionsError::Substitution { unresolved } => { assert_eq!(unresolved, vec!["also.missing".to_string(), "nope.absent".to_string()]); } other => panic!("expected Substitution, got {other:?}"), } } #[test] fn validation_catches_f_monthly_out_of_range() { let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_tier_mix_sum_off() { let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_founding_above_standard() { // `basic = 8` appears only in [tiers.founding] in the canonical fixture // (standard has `basic = 16`), so this substring is unambiguous. let t = FIXTURE.replace("basic = 8", "basic = 999"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_surplus_split_off() { let t = FIXTURE.replace("surplus_split_reserve = 0.20", "surplus_split_reserve = 0.30"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_rho_incident_above_annual() { let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn float_formatting_strips_trailing_zeros() { assert_eq!(format_float(22.0), "22"); assert_eq!(format_float(0.59), "0.59"); assert_eq!(format_float(61960.0), "61960"); assert_eq!(format_float(0.5), "0.5"); assert_eq!(format_float(1.085), "1.085"); } #[test] fn unknown_fields_are_ignored() { // Unknown sections shouldn't break load; they just won't appear in the // typed view but should still be in the flat lookup. let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n"); let a = Assumptions::parse(&extra).unwrap(); assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42))); } #[test] fn load_from_path_round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("a.toml"); std::fs::write(&path, FIXTURE).unwrap(); let a = Assumptions::load(&path).unwrap(); a.validate().unwrap(); assert!(a.get("derived.R_cap").is_some()); } }