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}