Skip to main content

rto_exec/
guidance.rs

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