1use anyhow::{Context, Result, ensure};
6#[cfg(test)]
7use googletest::{Result as GtestResult, prelude::*};
8use wowlab_common::markdown::{self, Doc, Table};
9use wowlab_fs::{file, path::Path};
10
11use super::types::{LedgerFormat, ManifestLedger};
12
13pub(super) fn render(ledger: &ManifestLedger, format: LedgerFormat) -> Result<String> {
14 let category_rows: usize = ledger.category_totals.values().sum();
15 let disposition_rows: usize = ledger.disposition_totals.values().sum();
16
17 ensure!(
18 category_rows == ledger.rows.len(),
19 "category totals account for {category_rows} rows, but the ledger has {}",
20 ledger.rows.len()
21 );
22 ensure!(
23 disposition_rows == ledger.rows.len(),
24 "disposition totals account for {disposition_rows} rows, but the ledger has {}",
25 ledger.rows.len()
26 );
27
28 match format {
29 LedgerFormat::Json => {
30 serde_json::to_string_pretty(ledger).context("failed to serialize manifest ledger")
31 }
32 LedgerFormat::Markdown => Ok(markdown(ledger)),
33 }
34}
35
36pub(super) fn emit(rendered: &str, output: Option<&Path>) -> Result<()> {
37 if let Some(path) = output {
38 file::write_text(path, rendered)
39 .with_context(|| format!("failed to write manifest ledger to {}", path.display()))?;
40 } else {
41 println!("{rendered}");
42 }
43
44 Ok(())
45}
46
47fn markdown(ledger: &ManifestLedger) -> String {
48 let summary = format!(
49 "Composed specs: {}. Items: {}. Operation rows: {}.",
50 ledger.composed_specs,
51 ledger.items,
52 ledger.rows.len()
53 );
54 let totals = Table::new().headers(["Category", "Operations"]).rows(
55 ledger
56 .category_totals
57 .iter()
58 .map(|(category, count)| [category.clone(), count.to_string()]),
59 );
60 let dispositions = Table::new().headers(["Disposition", "Operations"]).rows(
61 ledger
62 .disposition_totals
63 .iter()
64 .map(|(disposition, count)| [disposition.clone(), count.to_string()]),
65 );
66 let shapes = Table::new()
67 .headers(["Manifest behavior shape", "Observations", "Specs"])
68 .rows(ledger.behavior_shapes.iter().map(|shape| {
69 [
70 shape.shape.clone(),
71 shape.observations.to_string(),
72 shape.specs.to_string(),
73 ]
74 }));
75 let mut table = Table::new().headers([
76 "Spec/item",
77 "Manifest key",
78 "Spell IDs",
79 "Child IDs",
80 "Hook",
81 "Hook op",
82 "Category",
83 "DBC semantics",
84 "Disposition",
85 "Evidence",
86 "Specs",
87 "Operations",
88 ]);
89
90 for row in &ledger.rows {
91 let hook = match (&row.hook_file, &row.hook_function) {
92 (Some(file), Some(function)) => format!("{file}::{function}"),
93 _ => String::new(),
94 };
95
96 table = table.row([
97 row.spec_or_item.clone(),
98 markdown::code(format!("{}:{}", row.manifest_path, row.manifest_key)),
99 numbers(&row.owning_spell_ids),
100 numbers(&row.child_spell_ids),
101 markdown::code(hook),
102 row.hook_operation
103 .map(|value| value.to_string())
104 .unwrap_or_default(),
105 row.category.as_str().to_string(),
106 row.semantic_ids.join(", "),
107 row.disposition.as_str().to_string(),
108 row.evidence.clone(),
109 row.spec_count.to_string(),
110 row.operation_count.to_string(),
111 ]);
112 }
113
114 Doc::new()
115 .h1("Manifest Override Audit")
116 .blank()
117 .line(&summary)
118 .blank()
119 .line("The dated reference remains in `prompt.md`; every total below is derived from the manifests loaded for this invocation.")
120 .blank()
121 .h2("Live composed-manifest shapes")
122 .blank()
123 .raw(shapes.build_markdown())
124 .blank()
125 .h2("Live operation totals")
126 .blank()
127 .raw(totals.build_markdown())
128 .blank()
129 .raw(dispositions.build_markdown())
130 .blank()
131 .h2("Operation ledger")
132 .blank()
133 .raw(table.build_markdown())
134 .build()
135}
136
137fn numbers(values: &[u32]) -> String {
138 values
139 .iter()
140 .map(u32::to_string)
141 .collect::<Vec<_>>()
142 .join(", ")
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::manifest_ledger::types::{LedgerDisposition, LedgerRow, OperationCategory};
149
150 #[gtest]
151 fn markdown_contains_operation_level_columns() -> GtestResult<()> {
152 let ledger = ManifestLedger {
153 composed_specs: 1,
154 items: 0,
155 behavior_shapes: vec![],
156 category_totals: std::collections::BTreeMap::from([("damage".to_string(), 1)]),
157 disposition_totals: std::collections::BTreeMap::from([("generic_gap".to_string(), 1)]),
158 rows: vec![LedgerRow::new(
159 (
160 "fire_mage",
161 "specs/mage/fire/manifest.toml",
162 "spells.FIREBALL.damage",
163 ),
164 (
165 OperationCategory::Damage,
166 LedgerDisposition::GenericGap,
167 "DBC damage effect",
168 ),
169 )],
170 };
171
172 let output = markdown(&ledger);
173
174 verify_true!(output.contains("Manifest key"))?;
175 verify_true!(output.contains("Live composed-manifest shapes"))?;
176 verify_true!(output.contains("generic_gap"))?;
177 verify_true!(output.contains("spells.FIREBALL.damage"))?;
178
179 Ok(())
180 }
181
182 #[gtest]
183 fn render_rejects_totals_that_do_not_reconcile() -> GtestResult<()> {
184 let ledger = ManifestLedger {
185 composed_specs: 0,
186 items: 0,
187 behavior_shapes: vec![],
188 category_totals: std::collections::BTreeMap::new(),
189 disposition_totals: std::collections::BTreeMap::new(),
190 rows: vec![LedgerRow::new(
191 ("fire_mage", "manifest.toml", "spells.FIREBALL.damage"),
192 (
193 OperationCategory::Damage,
194 LedgerDisposition::GenericGap,
195 "DBC damage effect",
196 ),
197 )],
198 };
199
200 render(&ledger, LedgerFormat::Markdown).err().or_fail()?;
201
202 Ok(())
203 }
204}