Skip to main content

forge/manifest_ledger/
report.rs

1// #t(file: rust_println) CLI output and explicit report-file generation are intentional.
2// #t(file: rust_alloc_in_loop) Markdown table cells must own their rendered strings.
3// #t(file: rust_clone_in_loop) the shared Markdown table builder takes owned cell values.
4
5use 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}