Skip to main content

boxology_classifier/
report.rs

1const SCHEMA: &str = "boxology.classification-report@2";
2
3/// Renders a classification report in the canonical human-readable form.
4///
5/// The first line is `classification <verdict>`, followed by one `finding` line per report
6/// finding. Findings retain classifier report order and always carry a quoted `path` field,
7/// plus `kind`, `base`, and `submitted` excerpts (`-` when absent). Conditional findings append
8/// their `condition` value. Every quoted textual field applies the printable-character escape
9/// policy so hostile contract content cannot break the one-finding-per-line contract. The output
10/// is deterministic and has one trailing newline.
11pub fn render_text(report: &super::ClassificationReport) -> String {
12    let mut output = String::from("classification ");
13    output.push_str(report.verdict().canonical_name());
14    output.push('\n');
15    for finding in report.findings() {
16        output.push_str("finding ");
17        output.push_str(finding.code());
18        output.push_str(" path=\"");
19        push_escaped(&mut output, finding.path());
20        output.push_str("\" ");
21        output.push_str(finding.class().canonical_name());
22        output.push_str(" kind=\"");
23        push_escaped(&mut output, finding.kind());
24        output.push_str("\" base=");
25        push_text_excerpt(&mut output, finding.base_excerpt());
26        output.push_str(" submitted=");
27        push_text_excerpt(&mut output, finding.submitted_excerpt());
28        if let Some(condition) = finding.condition() {
29            output.push_str(" condition=\"");
30            push_escaped(&mut output, condition);
31            output.push('\"');
32        }
33        output.push('\n');
34    }
35    output
36}
37
38/// Renders a classification report as its canonical deterministic JSON mirror.
39///
40/// The field inventory is fixed as `schema`, `verdict`, and `findings` at the top level, and
41/// `code`, `path`, `kind`, `class`, `base`, `submitted`, and optional `condition` for each
42/// finding. `base` and `submitted` are always present (`null` when absent). The two-space
43/// indentation, field order, report order, and trailing newline are part of the output contract.
44/// `SCHEMA` is the sole version string: changing this inventory requires a schema version bump
45/// rather than an in-place field edit. Every string value applies the printable-character escape
46/// policy.
47pub fn render_json(report: &super::ClassificationReport) -> String {
48    let mut output = String::from("{\n  \"schema\": \"");
49    push_escaped(&mut output, SCHEMA);
50    output.push_str("\",\n  \"verdict\": \"");
51    push_escaped(&mut output, report.verdict().canonical_name());
52    output.push_str("\",\n  \"findings\": ");
53    if report.findings().is_empty() {
54        output.push_str("[]\n}\n");
55        return output;
56    }
57    output.push_str("[\n");
58    for (index, finding) in report.findings().iter().enumerate() {
59        if index != 0 {
60            output.push_str(",\n");
61        }
62        output.push_str("    {\n      \"code\": \"");
63        push_escaped(&mut output, finding.code());
64        output.push_str("\",\n      \"path\": \"");
65        push_escaped(&mut output, finding.path());
66        output.push_str("\",\n      \"kind\": \"");
67        push_escaped(&mut output, finding.kind());
68        output.push_str("\",\n      \"class\": \"");
69        push_escaped(&mut output, finding.class().canonical_name());
70        output.push_str("\",\n      \"base\": ");
71        push_json_excerpt(&mut output, finding.base_excerpt());
72        output.push_str(",\n      \"submitted\": ");
73        push_json_excerpt(&mut output, finding.submitted_excerpt());
74        if let Some(condition) = finding.condition() {
75            output.push_str(",\n      \"condition\": \"");
76            push_escaped(&mut output, condition);
77            output.push('\"');
78        }
79        output.push_str("\n    }");
80    }
81    output.push_str("\n  ]\n}\n");
82    output
83}
84
85fn push_text_excerpt(output: &mut String, excerpt: Option<&str>) {
86    match excerpt {
87        None => output.push('-'),
88        Some(value) => {
89            output.push('\"');
90            push_escaped(output, value);
91            output.push('\"');
92        }
93    }
94}
95
96fn push_json_excerpt(output: &mut String, excerpt: Option<&str>) {
97    match excerpt {
98        None => output.push_str("null"),
99        Some(value) => {
100            output.push('\"');
101            push_escaped(output, value);
102            output.push('\"');
103        }
104    }
105}
106
107/// Escapes a string for human quoted fields and JSON string values.
108///
109/// Printable-character policy: preserve ordinary Unicode text (letters, marks, numbers,
110/// punctuation, symbols, and ASCII space). Quotation mark and reverse solidus use JSON short
111/// escapes. Every excluded scalar uses a JSON-valid encoding (`\b` `\t` `\n` `\f` `\r` or
112/// `\uXXXX`, with supplementary scalars as a UTF-16 surrogate pair of `\uXXXX` units):
113/// - C0 controls U+0000–U+001F
114/// - DEL U+007F and C1 controls U+0080–U+009F (`char::is_control`)
115/// - non-ASCII whitespace and line/paragraph separators (`char::is_whitespace` except U+0020)
116/// - the complete Unicode 17.0.0 General_Category=Format (Cf) inventory
117fn push_escaped(output: &mut String, value: &str) {
118    for character in value.chars() {
119        match character {
120            '"' => output.push_str("\\\""),
121            '\\' => output.push_str("\\\\"),
122            '\u{08}' => output.push_str("\\b"),
123            '\t' => output.push_str("\\t"),
124            '\n' => output.push_str("\\n"),
125            '\u{0c}' => output.push_str("\\f"),
126            '\r' => output.push_str("\\r"),
127            ch if must_escape(ch) => push_unicode_escape(output, ch),
128            ch => output.push(ch),
129        }
130    }
131}
132
133fn must_escape(character: char) -> bool {
134    character.is_control()
135        || (character != ' ' && character.is_whitespace())
136        || is_layout_or_spoofing_format(character)
137}
138
139/// Complete Unicode 17.0.0 General_Category=Cf inventory from UCD UnicodeData.txt.
140/// Pinned to the repository toolchain where `std::char::UNICODE_VERSION == (17, 0, 0)`.
141fn is_layout_or_spoofing_format(character: char) -> bool {
142    character == '\u{00AD}'
143        || ('\u{0600}'..='\u{0605}').contains(&character)
144        || character == '\u{061C}'
145        || character == '\u{06DD}'
146        || character == '\u{070F}'
147        || ('\u{0890}'..='\u{0891}').contains(&character)
148        || character == '\u{08E2}'
149        || character == '\u{180E}'
150        || ('\u{200B}'..='\u{200F}').contains(&character)
151        || ('\u{202A}'..='\u{202E}').contains(&character)
152        || ('\u{2060}'..='\u{2064}').contains(&character)
153        || ('\u{2066}'..='\u{206F}').contains(&character)
154        || character == '\u{FEFF}'
155        || ('\u{FFF9}'..='\u{FFFB}').contains(&character)
156        || character == '\u{110BD}'
157        || character == '\u{110CD}'
158        || ('\u{13430}'..='\u{1343F}').contains(&character)
159        || ('\u{1BCA0}'..='\u{1BCA3}').contains(&character)
160        || ('\u{1D173}'..='\u{1D17A}').contains(&character)
161        || character == '\u{E0001}'
162        || ('\u{E0020}'..='\u{E007F}').contains(&character)
163}
164
165fn push_unicode_escape(output: &mut String, character: char) {
166    const HEX: &[u8; 16] = b"0123456789abcdef";
167    let push_unit = |output: &mut String, unit: u16| {
168        output.push_str("\\u");
169        output.push(HEX[((unit >> 12) & 0xf) as usize] as char);
170        output.push(HEX[((unit >> 8) & 0xf) as usize] as char);
171        output.push(HEX[((unit >> 4) & 0xf) as usize] as char);
172        output.push(HEX[(unit & 0xf) as usize] as char);
173    };
174    let code = u32::from(character);
175    if code < 0x1_0000 {
176        push_unit(output, code as u16);
177    } else {
178        let adjusted = code - 0x1_0000;
179        let high = 0xD800 + ((adjusted >> 10) as u16);
180        let low = 0xDC00 + ((adjusted & 0x3FF) as u16);
181        push_unit(output, high);
182        push_unit(output, low);
183    }
184}