Skip to main content

elenchus_solver/report/
human.rs

1//! The human-readable report rendering (the `Display for Report` path).
2use super::json::status_name;
3use super::{Report, Status, TraceReason, TraceStep};
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7use elenchus_compiler::{Origin, PlaceholderStatus, Value, kw};
8
9impl fmt::Display for Status {
10    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
11        f.write_str(status_name(*self))
12    }
13}
14
15/// Format provenance as `name (KIND)  [source:line]` for the human report.
16pub(crate) fn premise_tag(o: &Origin) -> String {
17    let name = o.premise.as_deref().unwrap_or("-");
18    alloc::format!("{} ({})  [{}:{}]", name, o.kind, o.source, o.line)
19}
20
21/// One derivation-trace line for the human report.
22pub(crate) fn trace_line(step: &TraceStep) -> String {
23    let v = match step.value {
24        Value::True => "TRUE",
25        Value::False => "FALSE",
26    };
27    match &step.reason {
28        TraceReason::Asserted(o) => {
29            alloc::format!(
30                "{} = {}   [{} {}:{}]",
31                step.atom,
32                v,
33                o.kind,
34                o.source,
35                o.line
36            )
37        }
38        TraceReason::Derived { origin, from } => alloc::format!(
39            "{} = {}   from {} ({})  [{}:{}]  <= {}",
40            step.atom,
41            v,
42            origin.premise.as_deref().unwrap_or("-"),
43            origin.kind,
44            origin.source,
45            origin.line,
46            from.join(", ")
47        ),
48    }
49}
50
51/// Indentation levels for the human report. This module is the **single** place
52/// leading whitespace is defined: every line is emitted through
53/// [`ReportWriter::line`] with one of these as the `indent` argument, so no
54/// format string ever carries leading spaces. To restyle the report, change a
55/// number here — not spaces scattered across `write!` calls.
56mod indent {
57    /// `RESULT:` / `SUMMARY:` / `EXIT_CODE:` — flush left.
58    pub const ROOT: usize = 0;
59    /// A section header: `CONFLICT` / `WARNING` / `CORE` / `RETRACT` / `DERIVED`
60    /// / `HINT` / `UNDERDETERMINED`.
61    pub const SECTION: usize = 2;
62    /// A line belonging to a section (conflict atoms, `blocked by:`, an `ASSUME`).
63    pub const ITEM: usize = 6;
64    /// A line nested under an item (a `why:` trace step, a `CORE` member).
65    pub const NESTED: usize = 8;
66}
67
68/// The human report's one output primitive. It owns the indentation rule so
69/// callers pass a semantic [`indent`] level and the text — never raw spaces.
70struct ReportWriter<'a, 'b> {
71    f: &'a mut fmt::Formatter<'b>,
72}
73
74impl ReportWriter<'_, '_> {
75    /// Write `indent` leading spaces, the formatted text, then a newline.
76    fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
77        write!(self.f, "{:width$}{}", "", args, width = indent)?;
78        self.f.write_str("\n")
79    }
80
81    /// Like [`line`](Self::line) but without the trailing newline — for the final
82    /// `EXIT_CODE` line, so the report ends exactly as it always has.
83    fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
84        write!(self.f, "{:width$}{}", "", args, width = indent)
85    }
86}
87
88/// `emit!(out, LEVEL, "fmt", args…)` — one indented report line. A thin wrapper
89/// over [`ReportWriter::line`] so call sites read `emit!(out, SECTION, …)` with
90/// the indent as an explicit parameter and zero leading spaces in the string.
91macro_rules! emit {
92    ($out:expr, $indent:expr, $($arg:tt)*) => {
93        $out.line($indent, format_args!($($arg)*))
94    };
95}
96
97impl Report {
98    /// Render the full human report. `show_placeholders` toggles the PLACEHOLDERS
99    /// section; the `Display` impl passes `true`, the CLI `--hide-params` flag
100    /// passes `false` to print only the verdict (the JSON form always keeps it).
101    fn render(&self, f: &mut fmt::Formatter<'_>, show_placeholders: bool) -> fmt::Result {
102        use indent::{ITEM, NESTED, ROOT, SECTION};
103        let mut out = ReportWriter { f };
104
105        emit!(out, ROOT, "RESULT: {}", self.status)?;
106
107        // A pure assumption clash: lead with the one action a small model needs
108        // and suppress the raw conflict / CORE pools (they would only echo the
109        // ASSUME clause). The verdict is still CONFLICT (exit code 2).
110        if !self.retract.is_empty() {
111            // Spell out what is wrong and why — this report is the debugger a
112            // small model reads. The commitments are sound; the hypotheses are
113            // the only dial to turn, so say so explicitly before listing them.
114            emit!(out, SECTION, "RETRACT  your FACTs and PREMISEs are fine.")?;
115            emit!(
116                out,
117                ITEM,
118                "But these ASSUME guesses cannot all be true together."
119            )?;
120            emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
121            for it in &self.retract {
122                emit!(
123                    out,
124                    ITEM,
125                    "ASSUME {}   [{}:{}]",
126                    it.label,
127                    it.origin.source,
128                    it.origin.line
129                )?;
130            }
131        } else {
132            for c in &self.conflicts {
133                emit!(out, SECTION, "CONFLICT  {}", premise_tag(&c.origin))?;
134                for a in &c.atoms {
135                    emit!(out, ITEM, "{}", a)?;
136                }
137                if !c.trace.is_empty() {
138                    emit!(out, ITEM, "why:")?;
139                    for step in &c.trace {
140                        emit!(out, NESTED, "{}", trace_line(step))?;
141                    }
142                }
143            }
144            if !self.unsat_core.is_empty() {
145                emit!(
146                    out,
147                    SECTION,
148                    "CORE  smallest jointly-unsatisfiable set ({}):",
149                    self.unsat_core.len()
150                )?;
151                for it in &self.unsat_core {
152                    let name = if it.label.is_empty() { "-" } else { &it.label };
153                    emit!(
154                        out,
155                        NESTED,
156                        "{} ({})  [{}:{}]",
157                        name,
158                        it.origin.kind,
159                        it.origin.source,
160                        it.origin.line
161                    )?;
162                }
163            }
164        }
165
166        // Many warnings often share one root cause (e.g. the same missing FACT),
167        // so the `fix:` line is deduped in the human report — each distinct fix
168        // prints once — to keep a wall of warnings readable. The full per-warning
169        // hint is still in the JSON for tools that select/filter programmatically.
170        let mut shown_fixes: Vec<&str> = Vec::new();
171        for w in &self.warnings {
172            emit!(out, SECTION, "WARNING   {}", premise_tag(&w.origin))?;
173            emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
174            if let Some(hint) = &w.hint
175                && !shown_fixes.contains(&hint.as_str())
176            {
177                shown_fixes.push(hint);
178                emit!(out, ITEM, "fix: {hint}")?;
179            }
180        }
181        if let Some(atom) = &self.underdetermined {
182            emit!(out, SECTION, "UNDERDETERMINED  an alternative model exists")?;
183            emit!(out, ITEM, "pin it down: add  FACT {atom}  or  NOT {atom}")?;
184        }
185        for d in &self.derived {
186            let v = match d.value {
187                Value::True => "TRUE",
188                Value::False => "FALSE",
189            };
190            emit!(
191                out,
192                SECTION,
193                "DERIVED   {} = {}   from {}",
194                d.atom,
195                v,
196                premise_tag(&d.origin)
197            )?;
198        }
199        for h in &self.hints {
200            emit!(
201                out,
202                SECTION,
203                "HINT      possible typo — '{}' and '{}' look like the same atom ({})",
204                h.a,
205                h.b,
206                h.reason
207            )?;
208        }
209        for o in &self.orphans {
210            // Reconstruct the surface line; `kind` already carries the polarity
211            // except for `ASSUME NOT`, where the value supplies it.
212            let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
213                alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
214            } else {
215                alloc::format!("{} {}", o.origin.kind, o.atom)
216            };
217            emit!(
218                out,
219                SECTION,
220                "ORPHAN    {} — not used by any premise or rule (no effect on the result)",
221                surface
222            )?;
223        }
224        for u in &self.unused_imports {
225            let via = match &u.alias {
226                Some(a) => alloc::format!("{} AS {}", u.domain, a),
227                None => u.domain.clone(),
228            };
229            emit!(
230                out,
231                SECTION,
232                "UNUSED IMPORT  {} — imported in {}:{} but never referenced (no effect on the result)",
233                via,
234                u.file,
235                u.line
236            )?;
237        }
238        // The PLACEHOLDERS section: every declared VAR port, how it resolved. A
239        // debugging aid for whoever wires the values; suppressed by `--hide-params`.
240        if show_placeholders {
241            for p in &self.placeholders {
242                match p.status {
243                    PlaceholderStatus::Supplied => emit!(
244                        out,
245                        SECTION,
246                        "PARAM     {} = {}   (supplied{})",
247                        p.key,
248                        bool_word(p.value),
249                        p.origin
250                            .as_deref()
251                            .map(|o| alloc::format!(": {o}"))
252                            .unwrap_or_default()
253                    )?,
254                    PlaceholderStatus::DefaultUsed => emit!(
255                        out,
256                        SECTION,
257                        "PARAM     {} = {}   (DEFAULT)",
258                        p.key,
259                        bool_word(p.value)
260                    )?,
261                    PlaceholderStatus::Unset => emit!(
262                        out,
263                        SECTION,
264                        "PARAM     {} = UNKNOWN   (no value supplied, no DEFAULT)",
265                        p.key
266                    )?,
267                }
268            }
269        }
270
271        let underdetermined = usize::from(self.status == Status::Underdetermined);
272        emit!(
273            out,
274            ROOT,
275            "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
276            self.conflicts.len(),
277            underdetermined,
278            self.warnings.len(),
279            self.derived.len()
280        )?;
281        out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
282    }
283
284    /// The full human report as an owned string. `show_placeholders = false`
285    /// (the CLI `--hide-params` flag) prints only the verdict, exactly as before
286    /// the ports feature. The JSON form (`to_json`) always keeps the section.
287    pub fn render_human(&self, show_placeholders: bool) -> String {
288        alloc::format!(
289            "{}",
290            HumanReport {
291                report: self,
292                show_placeholders
293            }
294        )
295    }
296}
297
298/// A `Display` adapter that renders a [`Report`] with a chosen `show_placeholders`
299/// flag (the plain `Display` impl always shows them).
300struct HumanReport<'a> {
301    report: &'a Report,
302    show_placeholders: bool,
303}
304
305impl fmt::Display for HumanReport<'_> {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        self.report.render(f, self.show_placeholders)
308    }
309}
310
311impl fmt::Display for Report {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        self.render(f, true)
314    }
315}
316
317/// `true`/`false` for a resolved port value (a supplied/default port is always
318/// `Some`); an unset port renders `UNKNOWN` at its own call site.
319pub(crate) fn bool_word(v: Option<bool>) -> &'static str {
320    match v {
321        Some(true) => "true",
322        Some(false) => "false",
323        None => "UNKNOWN",
324    }
325}