Skip to main content

rto_exec/
guidance.rs

1//! How a refusal is written, so that a way forward stays one.
2//!
3//! #426's rule is that a refusal **names the way forward**. A way forward you
4//! cannot paste is not one, and three of this crate's refusals drifted into
5//! exactly that at once — which says the way they were written invited it rather
6//! than that three people were careless.
7//!
8//! # The failure this type exists to make unrepresentable
9//!
10//! Rust's string-continuation escape lets a long message be wrapped in source:
11//!
12//! ```text
13//! "asking for isolation and getting \
14//!  execution is the one outcome"
15//! ```
16//!
17//! `\` before the newline swallows the newline **and the next line's
18//! indentation**, so that renders as one space. It is correct, and it is
19//! *fragile in a way that leaves no trace*: any edit that drops the backslash —
20//! a tool that rewrites the literal, a paste through something that treats `\`
21//! at end-of-line as its own continuation — silently turns nine columns of
22//! source indentation into nine spaces of user-visible text. Nothing fails to
23//! compile, no test that greps for a phrase notices, and the message still
24//! *reads* correctly in the source. It was found in shipped output:
25//!
26//! ```text
27//! Nothing ran, and nothing fell back to this host: asking for isolation and getting          execution
28//! ```
29//!
30//! # So prose is written as fragments, never as one wrapped literal
31//!
32//! [`Line::Note`] takes a **list of fragments**, each a complete literal on its
33//! own source line, joined with exactly one space when rendered. There is no
34//! continuation to lose, because there is none to begin with: wrapping is
35//! expressed by the list, which is data, rather than by an escape, which is
36//! punctuation. A fragment that somehow acquires stray whitespace is trimmed
37//! away rather than printed.
38//!
39//! [`Line::Command`] is the opposite and deliberately so: rendered **verbatim**,
40//! because its whitespace is its content — `for this run:  roteiro lint …` is
41//! aligned with the line below it on purpose, and a renderer that normalised it
42//! would break the thing it is there to preserve.
43//!
44//! # And the rules are checked where they cannot be skipped
45//!
46//! [`Guidance::defects`] states every rule; [`Guidance`]'s `Display` asserts them
47//! in debug builds. So **any** test that renders a message checks that message,
48//! and a new guidance is covered the first time anything prints it rather than
49//! the first time somebody remembers to write a test for it.
50//!
51//! @rto:0020
52
53use std::fmt;
54
55/// Where a note sits: under the sentence that introduced it.
56const NOTE_INDENT: &str = "\n  ";
57
58/// Where something to copy sits: one step further in, so the eye finds it.
59const COMMAND_INDENT: &str = "\n    ";
60
61/// One line of a refusal.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum Line {
65    /// Prose, as fragments joined by a single space.
66    ///
67    /// A list rather than one wrapped literal — see the module documentation.
68    /// Write one source line per fragment and let the join do the wrapping.
69    Note(&'static [&'static str]),
70    /// Something the reader is meant to copy, rendered exactly as written.
71    ///
72    /// Its internal whitespace is content, so nothing here is normalised. That
73    /// is also why it is a single literal rather than fragments: a command that
74    /// needed wrapping is a command nobody can paste.
75    Command(&'static str),
76}
77
78/// A refusal's body: what is wrong, and what to do about it.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Guidance(&'static [Line]);
81
82impl Guidance {
83    /// Build a guidance from its lines.
84    #[must_use]
85    pub const fn new(lines: &'static [Line]) -> Self {
86        Self(lines)
87    }
88
89    /// Its lines, for a caller that needs to inspect rather than print.
90    #[must_use]
91    pub fn lines(self) -> &'static [Line] {
92        self.0
93    }
94
95    /// Every way this guidance is malformed, in the order the lines appear.
96    ///
97    /// Empty for a well-formed one. Separated from the assertion so a test can
98    /// report *what* is wrong rather than only that something is, and so the
99    /// rules are readable in one place rather than spread through a renderer.
100    ///
101    /// The rules, and what each of them is for:
102    ///
103    /// - **A guidance says something.** An empty one is a refusal that names no
104    ///   way forward, which is the thing #426 forbids.
105    /// - **A fragment is trimmed and single-spaced.** This is the collapsed
106    ///   continuation, caught by its signature: source indentation arrives as a
107    ///   run of spaces inside a sentence.
108    /// - **A fragment is one line.** A `\n` inside one means somebody built a
109    ///   multi-line message by hand, around this type rather than with it.
110    /// - **A command survives a paste.** `$ ` before a name is a shell
111    ///   expansion that will not expand — measured, in a skip message that told
112    ///   the reader to run `--image $ ROTEIRO_TEST_LINT_IMAGE`.
113    #[must_use]
114    pub fn defects(self) -> Vec<String> {
115        let mut defects = Vec::new();
116        if self.0.is_empty() {
117            defects.push("the guidance is empty, so it names no way forward".to_owned());
118        }
119        for (index, line) in self.0.iter().enumerate() {
120            match line {
121                Line::Note(fragments) => {
122                    if fragments.is_empty() {
123                        defects.push(format!("line {index}: a note with no fragments"));
124                    }
125                    for (at, fragment) in fragments.iter().enumerate() {
126                        let where_ = format!("line {index} fragment {at}");
127                        if fragment.trim().is_empty() {
128                            defects.push(format!("{where_}: empty"));
129                        } else if *fragment != fragment.trim() {
130                            defects.push(format!(
131                                "{where_}: has leading or trailing whitespace ({fragment:?}) — \
132                                 fragments are joined with one space, so it is never needed"
133                            ));
134                        }
135                        if fragment.contains("  ") {
136                            defects.push(format!(
137                                "{where_}: contains a run of spaces ({fragment:?}) — the signature \
138                                 of source indentation that leaked into the message"
139                            ));
140                        }
141                        if fragment.contains('\n') || fragment.contains('\t') {
142                            defects.push(format!(
143                                "{where_}: contains a newline or tab — a note is one line, and \
144                                 more lines are more `Line`s"
145                            ));
146                        }
147                    }
148                }
149                Line::Command(command) => {
150                    if command.trim().is_empty() {
151                        defects.push(format!("line {index}: an empty command"));
152                    } else if *command != command.trim() {
153                        defects.push(format!(
154                            "line {index}: the command has leading or trailing whitespace \
155                             ({command:?}) — indentation is the renderer's"
156                        ));
157                    }
158                    if command.contains('\n') {
159                        defects.push(format!(
160                            "line {index}: the command spans lines — one nobody can paste in one \
161                             go is not a way forward"
162                        ));
163                    }
164                    if let Some(rest) = command.split("$ ").nth(1)
165                        && rest.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
166                    {
167                        defects.push(format!(
168                            "line {index}: `$ ` before a name ({command:?}) — the shell will not \
169                             expand it, so the command as printed does not work"
170                        ));
171                    }
172                    if let Some(column) = misaligned_run(command) {
173                        defects.push(format!(
174                            "line {index}: a run of spaces at column {column} ({command:?}) that \
175                             does not follow a label — alignment follows a `:` and anything else \
176                             is a wrapped literal, which a command may not be"
177                        ));
178                    }
179                }
180            }
181        }
182        defects
183    }
184}
185
186/// Where `command` has a run of spaces that is not deliberate alignment, if it
187/// does.
188///
189/// A [`Line::Command`] is rendered verbatim, so the run-of-spaces rule that
190/// protects prose cannot apply to it — its whitespace is its content. But it is
191/// exposed to the same hazard, because a command written as a wrapped literal
192/// collapses the same way, and *is unreadable when it does*.
193///
194/// The distinction that separates the two: legitimate alignment in these
195/// messages always follows a **label**, which ends in `:` —
196/// `for this run:  roteiro lint …` lines up with `standing:      add …`. A run
197/// of spaces anywhere else is a continuation that lost its backslash. So a
198/// command is written as one literal, and this is what says so.
199fn misaligned_run(command: &str) -> Option<usize> {
200    let bytes = command.as_bytes();
201    let mut at = 0;
202    while at < bytes.len() {
203        if bytes[at] != b' ' {
204            at += 1;
205            continue;
206        }
207        let start = at;
208        while at < bytes.len() && bytes[at] == b' ' {
209            at += 1;
210        }
211        // A single space is ordinary; a run is either alignment or a defect.
212        if at - start > 1 && start.checked_sub(1).map(|i| bytes[i]) != Some(b':') {
213            return Some(start);
214        }
215    }
216    None
217}
218
219impl fmt::Display for Guidance {
220    /// Render every line, each on its own, indented by what it is.
221    ///
222    /// A **leading** newline before each line rather than a trailing one, so a
223    /// caller can append a guidance to a sentence without having to know whether
224    /// it ends in one — `"…is not available here.{guidance}"` is the whole of
225    /// how these are used.
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        // Checked here rather than only in a test, so that any test which
228        // renders any message checks that message. A malformed guidance is a
229        // programming error and there is nothing a user could do about it, which
230        // is what makes an assertion the right shape rather than an error.
231        debug_assert!(
232            self.defects().is_empty(),
233            "malformed guidance: {}",
234            self.defects().join("; ")
235        );
236        for line in self.0 {
237            match line {
238                Line::Note(fragments) => {
239                    f.write_str(NOTE_INDENT)?;
240                    for (at, fragment) in fragments.iter().enumerate() {
241                        if at > 0 {
242                            f.write_str(" ")?;
243                        }
244                        f.write_str(fragment.trim())?;
245                    }
246                }
247                Line::Command(command) => {
248                    f.write_str(COMMAND_INDENT)?;
249                    f.write_str(command)?;
250                }
251            }
252        }
253        Ok(())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{Guidance, Line};
260
261    /// Fragments are joined by exactly one space, and the join is what does the
262    /// wrapping — so a message reads identically however its source is broken up.
263    #[test]
264    fn fragments_join_into_one_sentence_however_the_source_wrapped_them() {
265        let one = Guidance::new(&[Line::Note(&["asking for isolation and getting execution"])]);
266        let many = Guidance::new(&[Line::Note(&[
267            "asking for isolation",
268            "and getting",
269            "execution",
270        ])]);
271        assert_eq!(one.to_string(), many.to_string());
272        assert_eq!(
273            one.to_string(),
274            "\n  asking for isolation and getting execution"
275        );
276    }
277
278    /// The defect that started this: source indentation arriving as user-visible
279    /// spaces.
280    ///
281    /// Both shapes are refused — a fragment padded at its edge, and one with the
282    /// run embedded in the middle, which is what a collapsed continuation
283    /// actually produces. Checked through [`Guidance::defects`] rather than by
284    /// rendering, because rendering a malformed guidance now trips the assertion
285    /// in `Display`, which is the point of it.
286    #[test]
287    fn a_fragment_can_never_leak_source_indentation_into_the_output() {
288        const PADDED: Guidance = Guidance::new(&[Line::Note(&["getting", "         execution"])]);
289        const EMBEDDED: Guidance = Guidance::new(&[Line::Note(&["getting          execution"])]);
290
291        let defects = PADDED.defects();
292        assert!(
293            defects.iter().any(|d| d.contains("leading or trailing")),
294            "{defects:?}"
295        );
296
297        let defects = EMBEDDED.defects();
298        assert_eq!(defects.len(), 1, "{defects:?}");
299        assert!(defects[0].contains("run of spaces"), "{defects:?}");
300    }
301
302    /// `Display` trims anyway, and that is a backstop rather than a duplicate:
303    /// `debug_assert!` is compiled out of a release build, and a message that
304    /// reached a user with nine spaces in it would be the defect this module
305    /// exists for, shipped.
306    #[test]
307    fn rendering_trims_even_though_a_padded_fragment_is_already_a_defect() {
308        // Well-formed, so the assertion is satisfied; the fragments still go
309        // through `trim` on the way out.
310        const CLEAN: Guidance = Guidance::new(&[Line::Note(&["getting", "execution"])]);
311        assert_eq!(CLEAN.to_string(), "\n  getting execution");
312    }
313
314    /// A command's whitespace is its content, so it is rendered verbatim — the
315    /// two-space alignment in the escape below is deliberate and must survive.
316    #[test]
317    fn a_command_keeps_the_alignment_that_is_its_content() {
318        const ALIGNED: &str = "for this run:  roteiro lint <analyzer> --allow-unsandboxed";
319        const GUIDANCE: Guidance = Guidance::new(&[Line::Command(ALIGNED)]);
320        assert_eq!(GUIDANCE.to_string(), format!("\n    {ALIGNED}"));
321        assert!(
322            GUIDANCE.defects().is_empty(),
323            "internal alignment is content, not a defect: {:?}",
324            GUIDANCE.defects()
325        );
326    }
327
328    /// `--image $ VAR` was shipped. The shell would not expand it, so the
329    /// command as printed does not work — which is the one thing a way forward
330    /// may not be.
331    #[test]
332    fn a_command_whose_shell_expansion_is_broken_is_a_defect() {
333        const BROKEN: Guidance = Guidance::new(&[Line::Command(
334            "roteiro security prefetch --image $ ROTEIRO_TEST_LINT_IMAGE",
335        )]);
336        // The fixed form, and a `$` that is not an expansion at all, both pass.
337        const FIXED: Guidance = Guidance::new(&[Line::Command(
338            "roteiro security prefetch --image $ROTEIRO_TEST_LINT_IMAGE",
339        )]);
340        const NOT_A_VARIABLE: Guidance = Guidance::new(&[Line::Command("cost: $ 5")]);
341
342        let defects = BROKEN.defects();
343        assert_eq!(defects.len(), 1, "{defects:?}");
344        assert!(defects[0].contains("will not expand"), "{defects:?}");
345        for fine in [FIXED, NOT_A_VARIABLE] {
346            assert!(fine.defects().is_empty(), "{:?}", fine.defects());
347        }
348    }
349
350    /// A command is one literal. Wrapped like prose it collapses the same way,
351    /// and unlike prose it is then unpasteable — so the run-of-spaces rule
352    /// applies to it too, with alignment after a label carved out.
353    #[test]
354    fn a_command_may_align_after_a_label_and_may_not_wrap() {
355        const ALIGNED: Guidance = Guidance::new(&[
356            Line::Command("for this run:  roteiro lint <analyzer> --allow-unsandboxed"),
357            Line::Command(
358                "standing:      add `[lint] allow_unsandboxed = true` to ~/.roteiro/config.toml",
359            ),
360            Line::Command("cargo fetch --locked"),
361        ]);
362        const WRAPPED: Guidance = Guidance::new(&[Line::Command(
363            "roteiro security prefetch --analyzer clippy --allow-download                --image $X",
364        )]);
365
366        assert!(ALIGNED.defects().is_empty(), "{:?}", ALIGNED.defects());
367        let defects = WRAPPED.defects();
368        assert_eq!(defects.len(), 1, "{defects:?}");
369        assert!(
370            defects[0].contains("does not follow a label"),
371            "{defects:?}"
372        );
373    }
374
375    /// Everything else the rules cover, each stated as the thing it prevents.
376    #[test]
377    fn every_rule_names_the_defect_it_prevents() {
378        const EMPTY: Guidance = Guidance::new(&[]);
379        const NO_FRAGMENTS: Guidance = Guidance::new(&[Line::Note(&[])]);
380        const PADDED: Guidance = Guidance::new(&[Line::Note(&[" padded "])]);
381        const TWO_LINES: Guidance = Guidance::new(&[Line::Note(&["two\nlines"])]);
382        const MULTI_COMMAND: Guidance = Guidance::new(&[Line::Command("cargo fetch\ncargo build")]);
383        const INDENTED: Guidance = Guidance::new(&[Line::Command("  indented")]);
384
385        for (guidance, expected) in [
386            (EMPTY, "names no way forward"),
387            (NO_FRAGMENTS, "no fragments"),
388            (PADDED, "whitespace"),
389            (TWO_LINES, "a note is one line"),
390            (MULTI_COMMAND, "nobody can paste"),
391            (INDENTED, "whitespace"),
392        ] {
393            let defects = guidance.defects();
394            assert!(
395                defects.iter().any(|d| d.contains(expected)),
396                "expected a defect mentioning {expected:?}, got {defects:?}"
397            );
398        }
399    }
400
401    /// The leading newline is what lets a caller append a guidance to a sentence
402    /// without knowing whether that sentence ended in one.
403    #[test]
404    fn a_guidance_appends_to_a_sentence_rather_than_starting_a_document() {
405        let guidance = Guidance::new(&[Line::Note(&["do this"]), Line::Command("that")]);
406        assert_eq!(
407            format!("something is wrong.{guidance}"),
408            "something is wrong.\n  do this\n    that"
409        );
410        assert!(!guidance.to_string().ends_with('\n'));
411    }
412}