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}