Skip to main content

amont_agent_process/
guide.rs

1//! The preview guide (fleet rule `work.preview-is-guided`): what the person
2//! reads before they are asked to approve a preview.
3//!
4//! The person works on several projects at once and does not remember where
5//! this one stood, so "Ship repo@sha?" alone gives them nothing to decide
6//! with. `preview register` therefore takes a markdown guide, refuses one
7//! that lacks a required section ([`check`]), and renders it to a small,
8//! self-contained HTML page beside it ([`render`]).
9//!
10//! The converter is hand-rolled on purpose, for the constrained subset a
11//! guide needs: headings, paragraphs, bullet and numbered lists (one level of
12//! nesting), fenced code, block quotes, and inline bold, code, links, images
13//! and bare URLs. Everything else is text, and every text byte is escaped. A
14//! markdown crate would be a new dependency on a trust path (ADR-0020,
15//! `change.dependency-bar`) for a page nobody but the person reads.
16
17use std::path::Path;
18
19/// The sections a guide must carry, as H2 headings, in the order the page
20/// shows them.
21pub const SECTIONS: [&str; 5] = [
22    "Where we are",
23    "What you should see",
24    "Try it",
25    "Reference",
26    "Already checked",
27];
28
29const TRY_IT: usize = 2;
30const REFERENCE: usize = 3;
31
32/// A guide larger than this is not a guide.
33pub const MAX_BYTES: u64 = 1024 * 1024;
34
35/// One `## ` section: the heading as written and its body lines.
36#[derive(Debug, Clone)]
37struct Section<'a> {
38    heading: &'a str,
39    body: Vec<&'a str>,
40}
41
42/// A parsed guide: what precedes the first H2, and each section.
43#[derive(Debug)]
44pub struct Guide<'a> {
45    preamble: Vec<&'a str>,
46    sections: Vec<Section<'a>>,
47}
48
49/// The heading's words, without emoji, punctuation or case: `๐Ÿ“ Where we
50/// are:` and `where we are` are the same section.
51fn normalise(heading: &str) -> String {
52    let words: String = heading
53        .chars()
54        .map(|c| {
55            if c.is_alphanumeric() {
56                c.to_lowercase().next().unwrap_or(c)
57            } else {
58                ' '
59            }
60        })
61        .collect();
62    words.split_whitespace().collect::<Vec<_>>().join(" ")
63}
64
65/// Whether a heading as written names the section `name`: the same words,
66/// emoji, punctuation and case aside โ€” or the section's words followed by a
67/// note, introduced by `(`, a dash or `:` (`Try it (about 2 minutes)`,
68/// `Reference โ€” screenshots`). Other trailing words are a different heading:
69/// `Try it now` is not `Try it`.
70fn names_section(heading: &str, name: &str) -> bool {
71    if normalise(heading) == normalise(name) {
72        return true;
73    }
74    let h = heading.trim_start_matches(|c: char| !c.is_alphanumeric());
75    let Some(head) = h.get(..name.len()) else {
76        return false;
77    };
78    if !head.eq_ignore_ascii_case(name) {
79        return false;
80    }
81    let rest = &h[name.len()..];
82    if rest.chars().next().is_some_and(char::is_alphanumeric) {
83        return false;
84    }
85    let rest = rest.trim_start();
86    rest.is_empty() || rest.starts_with(['(', '-', 'โ€“', 'โ€”', ':'])
87}
88
89fn h2(line: &str) -> Option<&str> {
90    let rest = line.strip_prefix("## ")?;
91    Some(rest.trim().trim_end_matches('#').trim())
92}
93
94/// Lines of a fenced code block are never headings or steps.
95fn outside_fences(lines: &[&str]) -> Vec<bool> {
96    let mut inside = false;
97    lines
98        .iter()
99        .map(|l| {
100            if l.trim_start().starts_with("```") {
101                inside = !inside;
102                return false;
103            }
104            !inside
105        })
106        .collect()
107}
108
109pub fn parse(text: &str) -> Guide<'_> {
110    let lines: Vec<&str> = text.lines().collect();
111    let open = outside_fences(&lines);
112    let mut g = Guide {
113        preamble: Vec::new(),
114        sections: Vec::new(),
115    };
116    for (line, open) in lines.iter().zip(open) {
117        match h2(line).filter(|_| open) {
118            Some(heading) => g.sections.push(Section {
119                heading,
120                body: Vec::new(),
121            }),
122            None => match g.sections.last_mut() {
123                Some(s) => s.body.push(line),
124                None => g.preamble.push(line),
125            },
126        }
127    }
128    g
129}
130
131impl<'a> Guide<'a> {
132    /// The first section whose heading is `name`, emoji and case aside.
133    fn section(&self, name: &str) -> Option<&Section<'a>> {
134        self.sections
135            .iter()
136            .find(|s| names_section(s.heading, name))
137    }
138}
139
140/// A numbered list item: `1. `, `12) ` โ€” digits, then `.` or `)`, then a
141/// space.
142fn numbered(line: &str) -> Option<(usize, &str)> {
143    let t = line.trim_start();
144    let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
145    if digits == 0 || digits > 9 {
146        return None;
147    }
148    let rest = &t[digits..];
149    let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
150    if !(rest.is_empty() || rest.starts_with(' ')) {
151        return None;
152    }
153    Some((t[..digits].parse().ok()?, rest.trim_start()))
154}
155
156fn bullet(line: &str) -> Option<&str> {
157    let t = line.trim_start();
158    ["- ", "* ", "+ "]
159        .iter()
160        .find_map(|m| t.strip_prefix(m))
161        .or_else(|| (t == "-" || t == "*").then_some(""))
162}
163
164fn has_url(line: &str) -> bool {
165    line.contains("http://") || line.contains("https://")
166}
167
168/// What a guide lacks, one line each; empty when it is complete.
169pub fn check(text: &str) -> Vec<String> {
170    let g = parse(text);
171    let mut missing = Vec::new();
172    for name in SECTIONS {
173        match g.section(name) {
174            None => missing.push(format!("the section `## {name}`")),
175            Some(s) if s.body.iter().all(|l| l.trim().is_empty()) => {
176                missing.push(format!("any text under `## {name}`"))
177            }
178            Some(_) => {}
179        }
180    }
181    if let Some(s) = g.section(SECTIONS[TRY_IT]) {
182        let open = outside_fences(&s.body);
183        let steps = s
184            .body
185            .iter()
186            .zip(&open)
187            .any(|(l, o)| *o && numbered(l).is_some());
188        if !steps {
189            missing.push(
190                "a numbered step (`1. โ€ฆ`) under `## Try it`: each click, named by its label, and what should appear".to_string(),
191            );
192        }
193        if !s.body.iter().any(|l| has_url(l)) {
194            missing
195                .push("an http(s) URL under `## Try it`: where the first step starts".to_string());
196        }
197    }
198    missing
199}
200
201// --- mockup mode ------------------------------------------------------------
202
203/// The section a guide adds when its commit carries a picked mockup.
204pub const DIFFERENCES: &str = "Differences from the mockup";
205
206/// What an image shows, read from its file name: the picked design, the
207/// built screen, or the screen before the change.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209enum Role {
210    Design,
211    Built,
212    Before,
213}
214
215/// The role word in a file name and what is left of its stem around it:
216/// `artboard-empty.png` โ†’ (Design, `-empty`). The first matching word wins.
217fn role(src: &str) -> Option<(Role, String)> {
218    let name = file_name(src);
219    let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(&name);
220    [
221        ("artboard", Role::Design),
222        ("mockup", Role::Design),
223        ("after", Role::Built),
224        ("live", Role::Built),
225        ("before", Role::Before),
226    ]
227    .iter()
228    .find_map(|(word, r)| {
229        let i = stem.find(word)?;
230        Some((*r, format!("{}{}", &stem[..i], &stem[i + word.len()..])))
231    })
232}
233
234/// A design image and the built screen it is compared with, for one state.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct Proof {
237    /// What distinguishes this state's file names (`-empty`); empty for the
238    /// default pair.
239    pub state: String,
240    pub design: String,
241    pub design_alt: String,
242    pub built: String,
243    pub built_alt: String,
244}
245
246/// Every image in the guide with its alt text.
247fn images_with_alt(text: &str) -> Vec<(String, String)> {
248    let mut found = Vec::new();
249    let mut rest = text;
250    while let Some(i) = rest.find("![") {
251        rest = &rest[i + 2..];
252        let Some((alt, src, after)) = link_parts(rest) else {
253            continue;
254        };
255        found.push((alt.to_string(), src.to_string()));
256        rest = after;
257    }
258    found
259}
260
261/// The design/built pairs, matched by what their names share besides the
262/// role word: `artboard.png` with `after.png`, `artboard-empty.png` with
263/// `live-empty.png`.
264pub fn proofs(text: &str) -> Vec<Proof> {
265    let all = images_with_alt(text);
266    let mut out: Vec<Proof> = Vec::new();
267    for (alt, src) in &all {
268        let Some((Role::Design, key)) = role(src) else {
269            continue;
270        };
271        if out.iter().any(|p| p.state == key) {
272            continue;
273        }
274        let built = all
275            .iter()
276            .find(|(_, s)| role(s) == Some((Role::Built, key.clone())));
277        if let Some((balt, bsrc)) = built {
278            out.push(Proof {
279                state: key,
280                design: src.clone(),
281                design_alt: alt.clone(),
282                built: bsrc.clone(),
283                built_alt: balt.clone(),
284            });
285        }
286    }
287    out
288}
289
290/// The image the guide shows as the screen before the change, if any.
291fn before_image(text: &str) -> Option<(String, String)> {
292    images_with_alt(text)
293        .into_iter()
294        .find(|(_, s)| matches!(role(s), Some((Role::Before, _))))
295}
296
297/// A local image source as a file: relative to the guide's folder, or an
298/// absolute path, or a `file://` URL. A remote URL is `None`.
299fn local_file(src: &str, folder: &Path) -> Option<std::path::PathBuf> {
300    let s = src.trim();
301    if let Some(p) = s.strip_prefix("file://") {
302        return Some(std::path::PathBuf::from(p));
303    }
304    if s.starts_with('/') {
305        return Some(std::path::PathBuf::from(s));
306    }
307    if s.contains(':') {
308        return None;
309    }
310    Some(folder.join(s))
311}
312
313/// The `Viewport: <w>px ยท <theme>` line, if a body carries one.
314fn viewport(lines: &[&str]) -> Option<String> {
315    lines.iter().find_map(|l| {
316        let low = l.to_ascii_lowercase();
317        let i = low.find("viewport")?;
318        let rest = low[i + "viewport".len()..].trim_start_matches(['*', ':', ' ']);
319        let digits = rest.chars().take_while(char::is_ascii_digit).count();
320        (digits > 0 && rest[digits..].trim_start().starts_with("px"))
321            .then(|| plain(l.trim().trim_start_matches(['-', '*', ' '])))
322    })
323}
324
325/// How the differences section reads: `Ok` for `None` or bullets that each
326/// say `fixed:` or `deliberate: <reason>`, else what is wrong.
327fn differences(lines: &[&str]) -> Result<(), String> {
328    let mut bullets = 0;
329    let mut unclassified = 0;
330    let mut none = false;
331    for l in lines {
332        let low = l.trim().to_ascii_lowercase();
333        if low.trim_end_matches('.') == "none" {
334            none = true;
335            continue;
336        }
337        let Some(b) = bullet(l) else { continue };
338        bullets += 1;
339        let b = b.trim().to_ascii_lowercase();
340        let b = b.trim_start_matches("**");
341        let classified = ["fixed", "deliberate"].iter().any(|w| {
342            b.strip_prefix(w)
343                .map(|r| r.trim_start_matches("**"))
344                .and_then(|r| r.strip_prefix(':'))
345                .is_some_and(|r| !r.trim().is_empty())
346        });
347        if !classified {
348            unclassified += 1;
349        }
350    }
351    if unclassified > 0 {
352        return Err(format!(
353            "{unclassified} bullet(s) under `## {DIFFERENCES}` say neither `fixed: โ€ฆ` nor `deliberate: <reason>`"
354        ));
355    }
356    if bullets == 0 && !none {
357        return Err(format!(
358            "`## {DIFFERENCES}` lists nothing: write `None`, or one bullet per difference (`- fixed: โ€ฆ` / `- deliberate: <reason>`)"
359        ));
360    }
361    Ok(())
362}
363
364/// What a guide lacks in mockup mode โ€” its commit carries the picked
365/// artboards of `screens` โ€” on top of [`check`]. Images resolve against
366/// `folder`, the guide's own.
367pub fn check_mockup(text: &str, screens: &[String], folder: &Path) -> Vec<String> {
368    let g = parse(text);
369    let mut missing = Vec::new();
370    let reference: Vec<&str> = g
371        .section(SECTIONS[REFERENCE])
372        .map(|s| s.body.clone())
373        .unwrap_or_default();
374    for screen in screens {
375        if !reference.iter().any(|l| l.contains(screen.as_str())) {
376            missing.push(format!("the screen's path `{screen}` under `## Reference`"));
377        }
378    }
379    if viewport(&reference).is_none() {
380        missing.push(
381            "a viewport line under `## Reference`: `Viewport: 1120px ยท light`, the width and theme both images were taken at".to_string(),
382        );
383    }
384    let found = proofs(text);
385    if !found.iter().any(|p| p.state.is_empty()) {
386        missing.push(format!(
387            "the picked artboard beside the built screen under `## Reference`: `artboard.png` and `after.png` in {}",
388            folder.display()
389        ));
390    }
391    for p in &found {
392        for src in [&p.design, &p.built] {
393            match local_file(src, folder) {
394                Some(f) if f.is_file() => {}
395                Some(f) => missing.push(format!(
396                    "the image {} (the guide shows `{src}`)",
397                    f.display()
398                )),
399                None => missing.push(format!(
400                    "`{src}` as a file beside the guide: a remote image cannot be checked"
401                )),
402            }
403        }
404    }
405    match g.section(DIFFERENCES) {
406        None => missing.push(format!("the section `## {DIFFERENCES}`")),
407        Some(s) => {
408            if let Err(why) = differences(&s.body) {
409                missing.push(why);
410            }
411        }
412    }
413    missing
414}
415
416/// Width of a PNG, from its IHDR chunk; `None` for anything else.
417pub fn png_width(path: &Path) -> Option<u32> {
418    use std::io::Read;
419    let mut head = [0u8; 24];
420    std::fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
421    if head[..8] != [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] || &head[12..16] != b"IHDR" {
422        return None;
423    }
424    Some(u32::from_be_bytes([head[16], head[17], head[18], head[19]]))
425}
426
427/// Pairs whose two PNGs differ in width by more than a tenth: a divergence
428/// would read as a scaling difference.
429pub fn width_warnings(text: &str, folder: &Path) -> Vec<String> {
430    proofs(text)
431        .iter()
432        .filter_map(|p| {
433            let d = png_width(&local_file(&p.design, folder)?)?;
434            let b = png_width(&local_file(&p.built, folder)?)?;
435            let (lo, hi) = (d.min(b), d.max(b));
436            (hi - lo > hi / 10).then(|| {
437                format!(
438                    "{} is {d}px wide and {} is {b}px: take the built screen at the artboard's width",
439                    p.design, p.built
440                )
441            })
442        })
443        .collect()
444}
445
446// --- rendering ------------------------------------------------------------
447
448pub fn escape(s: &str) -> String {
449    let mut out = String::with_capacity(s.len());
450    for c in s.chars() {
451        match c {
452            '&' => out.push_str("&amp;"),
453            '<' => out.push_str("&lt;"),
454            '>' => out.push_str("&gt;"),
455            '"' => out.push_str("&quot;"),
456            '\'' => out.push_str("&#39;"),
457            c => out.push(c),
458        }
459    }
460    out
461}
462
463/// A URL the page may carry in `href`/`src`: http(s), mailto, file, or a
464/// scheme-less relative path. `javascript:` and friends are `None`, and the
465/// markdown around them renders as text.
466fn safe_url(url: &str) -> Option<&str> {
467    let u = url.trim();
468    if u.is_empty() || u.chars().any(|c| c.is_whitespace() || c.is_control()) {
469        return None;
470    }
471    let scheme_end = u.find(':');
472    let path_start = u.find(['/', '?', '#']).unwrap_or(u.len());
473    match scheme_end {
474        Some(i) if i < path_start => {
475            let scheme = u[..i].to_ascii_lowercase();
476            // A Windows drive letter (`C:\โ€ฆ`) is a path, not a scheme.
477            let drive = i == 1 && u[..1].chars().all(|c| c.is_ascii_alphabetic());
478            (drive || matches!(scheme.as_str(), "http" | "https" | "mailto" | "file")).then_some(u)
479        }
480        _ => Some(u),
481    }
482}
483
484/// The file name of an image source, lowercased.
485fn file_name(src: &str) -> String {
486    src.rsplit(['/', '\\'])
487        .next()
488        .unwrap_or(src)
489        .to_ascii_lowercase()
490}
491
492/// Every image source the guide references, in order.
493pub fn images(text: &str) -> Vec<String> {
494    let mut found = Vec::new();
495    let mut rest = text;
496    while let Some(i) = rest.find("![") {
497        rest = &rest[i + 2..];
498        let Some((_, src, after)) = link_parts(rest) else {
499            continue;
500        };
501        found.push(src.to_string());
502        rest = after;
503    }
504    found
505}
506
507/// After `[` or `![`: `alt](src)` โ†’ (alt, src, what follows).
508fn link_parts(s: &str) -> Option<(&str, &str, &str)> {
509    let close = s.find("](")?;
510    let text = &s[..close];
511    if text.contains('\n') {
512        return None;
513    }
514    let after = &s[close + 2..];
515    let end = after.find(')')?;
516    let src = after[..end].trim();
517    // `(src "title")`: the title is dropped.
518    let src = src.split_once(" \"").map(|(s, _)| s).unwrap_or(src);
519    Some((text, src, &after[end + 1..]))
520}
521
522/// The before/after pair, when the guide references an image whose file
523/// name says `before` and another whose name says `after`.
524fn pair(text: &str) -> Option<(String, String)> {
525    let all = images(text);
526    let before = all.iter().find(|s| file_name(s).contains("before"))?;
527    let after = all
528        .iter()
529        .find(|s| *s != before && file_name(s).contains("after"))?;
530    Some((before.clone(), after.clone()))
531}
532
533struct Ctx<'p> {
534    /// Sources rendered in the Reference block, so skipped inline.
535    paired: &'p [String],
536}
537
538impl Ctx<'_> {
539    fn is_paired(&self, src: &str) -> bool {
540        self.paired.iter().any(|s| s == src)
541    }
542}
543
544fn img(alt: &str, src: &str) -> String {
545    format!(
546        "<img src=\"{}\" alt=\"{}\" loading=\"lazy\">",
547        escape(src),
548        escape(alt)
549    )
550}
551
552/// Inline markdown to HTML: `code`, **bold**, ![alt](src), [text](url), a
553/// bare http(s) URL, `\` escapes. Everything else is escaped text.
554fn inline(s: &str, ctx: &Ctx) -> String {
555    let mut out = String::new();
556    let mut i = 0;
557    let b = s.as_bytes();
558    while i < s.len() {
559        let rest = &s[i..];
560        if rest.starts_with('\\') && rest.len() > 1 {
561            let c = rest[1..].chars().next().unwrap_or(' ');
562            if c.is_ascii_punctuation() {
563                out.push_str(&escape(&c.to_string()));
564                i += 1 + c.len_utf8();
565                continue;
566            }
567        }
568        if let Some(r) = rest.strip_prefix('`') {
569            if let Some(end) = r.find('`') {
570                out.push_str(&format!("<code>{}</code>", escape(&r[..end])));
571                i += end + 2;
572                continue;
573            }
574        }
575        if let Some(r) = rest.strip_prefix("**") {
576            if let Some(end) = r.find("**").filter(|&e| e > 0) {
577                out.push_str(&format!("<strong>{}</strong>", inline(&r[..end], ctx)));
578                i += end + 4;
579                continue;
580            }
581        }
582        if let Some(r) = rest.strip_prefix("![") {
583            if let Some((alt, src, after)) = link_parts(r) {
584                if let Some(src) = safe_url(src) {
585                    if !ctx.is_paired(src) {
586                        out.push_str(&img(alt, src));
587                    }
588                    i = s.len() - after.len();
589                    continue;
590                }
591            }
592        }
593        if let Some(r) = rest.strip_prefix('[') {
594            if let Some((text, url, after)) = link_parts(r) {
595                if let Some(url) = safe_url(url) {
596                    out.push_str(&format!(
597                        "<a href=\"{}\">{}</a>",
598                        escape(url),
599                        inline(text, ctx)
600                    ));
601                    i = s.len() - after.len();
602                    continue;
603                }
604            }
605        }
606        let boundary = i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'/');
607        if boundary && (rest.starts_with("http://") || rest.starts_with("https://")) {
608            let end = rest
609                .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"')
610                .unwrap_or(rest.len());
611            let url = rest[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
612            out.push_str(&format!("<a href=\"{0}\">{0}</a>", escape(url)));
613            i += url.len();
614            continue;
615        }
616        let c = rest.chars().next().unwrap_or(' ');
617        out.push_str(&escape(&c.to_string()));
618        i += c.len_utf8();
619    }
620    out
621}
622
623fn indent(line: &str) -> usize {
624    line.len() - line.trim_start().len()
625}
626
627/// A list marker at the start of `line`: (ordered, start number, text,
628/// width of indent plus marker).
629fn marker(line: &str) -> Option<(bool, usize, &str, usize)> {
630    let ind = indent(line);
631    if let Some(text) = bullet(line) {
632        return Some((false, 1, text, ind + 2));
633    }
634    let (n, text) = numbered(line)?;
635    let t = line.trim_start();
636    let width = t.len() - text.len();
637    Some((true, n, text, ind + width.max(1)))
638}
639
640fn heading(line: &str) -> Option<(usize, &str)> {
641    let t = line.trim_start();
642    let level = t.chars().take_while(|&c| c == '#').count();
643    if level == 0 || level > 6 {
644        return None;
645    }
646    let rest = &t[level..];
647    if !(rest.is_empty() || rest.starts_with(' ')) {
648        return None;
649    }
650    Some((level, rest.trim().trim_end_matches('#').trim()))
651}
652
653fn starts_block(line: &str) -> bool {
654    let t = line.trim_start();
655    t.starts_with("```") || t.starts_with('>') || heading(line).is_some() || marker(line).is_some()
656}
657
658/// Block-level markdown to HTML. `tight` renders a lone paragraph without
659/// `<p>`, as a list item's text reads.
660fn blocks(lines: &[&str], ctx: &Ctx, tight: bool, out: &mut String) {
661    let mut i = 0;
662    while i < lines.len() {
663        let line = lines[i];
664        let t = line.trim();
665        if t.is_empty() {
666            i += 1;
667            continue;
668        }
669        if t.starts_with("```") {
670            let mut j = i + 1;
671            let mut code = Vec::new();
672            while j < lines.len() && !lines[j].trim_start().starts_with("```") {
673                code.push(lines[j]);
674                j += 1;
675            }
676            out.push_str(&format!(
677                "<pre><code>{}</code></pre>\n",
678                escape(&code.join("\n"))
679            ));
680            i = j + 1;
681            continue;
682        }
683        if let Some((level, text)) = heading(line) {
684            // H1 and H2 belong to the page itself.
685            let level = level.max(3);
686            out.push_str(&format!("<h{level}>{}</h{level}>\n", inline(text, ctx)));
687            i += 1;
688            continue;
689        }
690        if t.starts_with('>') {
691            let mut quoted = Vec::new();
692            while i < lines.len() && lines[i].trim_start().starts_with('>') {
693                let q = lines[i].trim_start()[1..].strip_prefix(' ');
694                quoted.push(q.unwrap_or(&lines[i].trim_start()[1..]));
695                i += 1;
696            }
697            out.push_str("<blockquote>\n");
698            blocks(&quoted, ctx, false, out);
699            out.push_str("</blockquote>\n");
700            continue;
701        }
702        if let Some((ordered, start, _, _)) = marker(line) {
703            i = list(lines, i, ordered, start, ctx, out);
704            continue;
705        }
706        // A paragraph: until a blank line or another block.
707        let mut para = vec![t];
708        i += 1;
709        while i < lines.len() && !lines[i].trim().is_empty() && !starts_block(lines[i]) {
710            para.push(lines[i].trim());
711            i += 1;
712        }
713        let text = para.join(" ");
714        let only_image = text.starts_with("![") && images(&text).len() == 1 && text.ends_with(')');
715        if only_image {
716            let html = inline(&text, ctx);
717            if !html.is_empty() {
718                let alt = &text[2..text.find("](").unwrap_or(2)];
719                out.push_str(&format!(
720                    "<figure>{html}<figcaption>{}</figcaption></figure>\n",
721                    escape(alt)
722                ));
723            }
724        } else {
725            let html = inline(&text, ctx);
726            let html = html.trim();
727            if html.is_empty() {
728                // Only images shown elsewhere (the before/after pair).
729            } else if tight {
730                out.push_str(html);
731            } else {
732                out.push_str(&format!("<p>{html}</p>\n"));
733            }
734        }
735    }
736}
737
738/// One list, starting at `lines[i]`; returns the index after it.
739fn list(
740    lines: &[&str],
741    mut i: usize,
742    ordered: bool,
743    start: usize,
744    ctx: &Ctx,
745    out: &mut String,
746) -> usize {
747    let base = indent(lines[i]);
748    let (open, close) = if ordered {
749        (
750            if start == 1 {
751                "<ol>".to_string()
752            } else {
753                format!("<ol start=\"{start}\">")
754            },
755            "</ol>",
756        )
757    } else {
758        ("<ul>".to_string(), "</ul>")
759    };
760    out.push_str(&open);
761    out.push('\n');
762    while i < lines.len() {
763        let Some((o, _, text, width)) = marker(lines[i]) else {
764            break;
765        };
766        if o != ordered || indent(lines[i]) != base {
767            break;
768        }
769        // The item: its first line, then every line that is blank or
770        // indented past the list's own indent, up to the next item.
771        let mut body = vec![text];
772        let mut j = i + 1;
773        while j < lines.len() {
774            let l = lines[j];
775            if l.trim().is_empty() {
776                // A blank line ends the item unless indented content follows.
777                let next = lines[j + 1..].iter().find(|l| !l.trim().is_empty());
778                if next.is_some_and(|n| indent(n) > base) {
779                    body.push("");
780                    j += 1;
781                    continue;
782                }
783                break;
784            }
785            if indent(l) <= base && (marker(l).is_some() || starts_block(l)) {
786                break;
787            }
788            let strip = indent(l).min(width);
789            body.push(&l[strip..]);
790            j += 1;
791        }
792        let tight = !body.contains(&"");
793        out.push_str("<li>");
794        blocks(&body, ctx, tight, out);
795        out.push_str("</li>\n");
796        i = j;
797        // Blank lines between items keep the list going.
798        while i < lines.len() && lines[i].trim().is_empty() {
799            let next = lines[i + 1..].iter().find(|l| !l.trim().is_empty());
800            if next.is_some_and(|n| indent(n) == base && marker(n).is_some_and(|m| m.0 == ordered))
801            {
802                i += 1;
803            } else {
804                break;
805            }
806        }
807    }
808    out.push_str(close);
809    out.push('\n');
810    i
811}
812
813/// Plain text of a line of inline markdown, for `<title>`: tags stripped
814/// from our own rendering (the text in it is already escaped).
815fn plain(s: &str) -> String {
816    let html = inline(s, &Ctx { paired: &[] });
817    let mut out = String::new();
818    let mut tag = false;
819    for c in html.chars() {
820        match c {
821            '<' => tag = true,
822            '>' => tag = false,
823            c if !tag => out.push(c),
824            _ => {}
825        }
826    }
827    out.trim().to_string()
828}
829
830fn slug(name: &str) -> String {
831    normalise(name).replace(' ', "-")
832}
833
834/// What the page is made from.
835pub struct Page<'a> {
836    /// `repo@shortsha`.
837    pub label: &'a str,
838    pub commit: &'a str,
839    pub url: &'a str,
840}
841
842/// The first line of "Where we are", list marker and markup aside.
843fn headline(g: &Guide) -> String {
844    let Some(s) = g.section(SECTIONS[0]) else {
845        return String::new();
846    };
847    let first = s
848        .body
849        .iter()
850        .map(|l| l.trim())
851        .find(|l| !l.is_empty())
852        .unwrap_or("");
853    let first = bullet(first)
854        .or_else(|| numbered(first).map(|(_, t)| t))
855        .unwrap_or(first);
856    plain(first)
857}
858
859/// The guide as one self-contained HTML page: inline CSS, no script, no
860/// external asset; images are the guide's own, relative to the page.
861pub fn render(text: &str, page: &Page) -> String {
862    let g = parse(text);
863    let found = proofs(text);
864    let paired = if found.is_empty() { pair(text) } else { None };
865    let before = if found.is_empty() {
866        None
867    } else {
868        before_image(text)
869    };
870    let mut skipped: Vec<String> = Vec::new();
871    if let Some((b, a)) = &paired {
872        skipped.extend([b.clone(), a.clone()]);
873    }
874    for p in &found {
875        skipped.extend([p.design.clone(), p.built.clone()]);
876    }
877    if let Some((_, b)) = &before {
878        skipped.push(b.clone());
879    }
880    let ctx = Ctx { paired: &skipped };
881    let direction = direction(text);
882    let headline = headline(&g);
883    let title = if headline.is_empty() {
884        escape(page.label)
885    } else {
886        format!("{} โ€” {headline}", escape(page.label))
887    };
888    let mut body = String::new();
889    // The preamble, less any H1: the page has its own.
890    let pre: Vec<&str> = g
891        .preamble
892        .iter()
893        .copied()
894        .filter(|l| !l.trim_start().starts_with("# "))
895        .collect();
896    if pre.iter().any(|l| !l.trim().is_empty()) {
897        body.push_str("<section class=\"intro\">\n");
898        blocks(&pre, &ctx, false, &mut body);
899        body.push_str("</section>\n");
900    }
901    // The required sections in their order, then any other in the guide's.
902    let mut order: Vec<&Section> = SECTIONS.iter().filter_map(|n| g.section(n)).collect();
903    for s in &g.sections {
904        if !order.iter().any(|o| std::ptr::eq(*o, s)) {
905            order.push(s);
906        }
907    }
908    let reference = normalise(SECTIONS[REFERENCE]);
909    for s in order {
910        let id = slug(s.heading);
911        body.push_str(&format!(
912            "<section id=\"{}\">\n<h2>{}</h2>\n",
913            escape(&id),
914            inline(s.heading, &ctx)
915        ));
916        if normalise(s.heading) == reference {
917            let view = viewport(&s.body).map(|v| escape(&v)).unwrap_or_default();
918            for (i, p) in found.iter().enumerate() {
919                let state = p.state.trim_matches(['-', '_', ' ']);
920                let design_caption = match &direction {
921                    Some(d) => format!("Mockup, direction {d}"),
922                    None => "Mockup".to_string(),
923                };
924                body.push_str(&format!(
925                    "<div class=\"proof\">\n{state_line}<input type=\"checkbox\" id=\"overlay-{i}\" class=\"overlay\"><label for=\"overlay-{i}\">Overlay the built screen on the mockup</label>\n<div class=\"stack\">\n<figure class=\"design\"><a href=\"{dsrc}\">{dimg}</a><figcaption>{dcap}{view_line}</figcaption></figure>\n<figure class=\"built\"><a href=\"{bsrc}\">{bimg}</a><figcaption>Built, <code>{sha}</code>{view_line}</figcaption></figure>\n</div>\n</div>\n",
926                    state_line = if state.is_empty() {
927                        String::new()
928                    } else {
929                        format!("<p class=\"state\">State: {}</p>\n", escape(state))
930                    },
931                    dsrc = escape(&p.design),
932                    dimg = img(alt_or(&p.design_alt, &design_caption), &p.design),
933                    dcap = escape(&design_caption),
934                    bsrc = escape(&p.built),
935                    bimg = img(alt_or(&p.built_alt, "Built screen"), &p.built),
936                    sha = escape(&page.commit[..page.commit.len().min(7)]),
937                    view_line = if view.is_empty() { String::new() } else { format!(" ยท {view}") },
938                ));
939            }
940            if let Some((alt, src)) = &before {
941                body.push_str(&format!(
942                    "<figure class=\"before\"><a href=\"{}\">{}</a><figcaption>Before</figcaption></figure>\n",
943                    escape(src),
944                    img(alt_or(alt, "Before"), src)
945                ));
946            }
947            if let Some((before, after)) = &paired {
948                body.push_str(&format!(
949                    "<div class=\"pair\">\n<figure>{}<figcaption>Before</figcaption></figure>\n<figure>{}<figcaption>After</figcaption></figure>\n</div>\n",
950                    img("Before", before),
951                    img("After", after)
952                ));
953            }
954        }
955        blocks(&s.body, &ctx, false, &mut body);
956        body.push_str("</section>\n");
957    }
958    let url = safe_url(page.url).unwrap_or("#");
959    format!(
960        "<!doctype html>
961<html lang=\"en\">
962<head>
963<meta charset=\"utf-8\">
964<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
965<meta name=\"color-scheme\" content=\"light dark\">
966<title>{title}</title>
967<style>{STYLE}</style>
968</head>
969<body>
970<main>
971<header>
972<p class=\"label\">{label}</p>
973<h1>{h1}</h1>
974<p><a class=\"open\" href=\"{url}\" target=\"_blank\" rel=\"noopener\">Open the app</a></p>
975<p class=\"url\"><code>{url_text}</code></p>
976</header>
977{body}<footer>Guide for commit <code>{commit}</code> ยท rendered by <code>amont-agent preview register</code></footer>
978</main>
979</body>
980</html>
981",
982        label = escape(page.label),
983        h1 = if headline.is_empty() {
984            escape(page.label)
985        } else {
986            headline.clone()
987        },
988        url = escape(url),
989        url_text = escape(page.url),
990        commit = escape(page.commit),
991    )
992}
993
994const STYLE: &str = "
995:root{--bg:#fbfbfa;--fg:#1d1d1f;--muted:#5f6368;--line:#dcdcdc;--card:#fff;--accent:#0b57d0;--accent-fg:#fff;--code:#f1f1ef}
996@media (prefers-color-scheme: dark){:root{--bg:#161618;--fg:#ececec;--muted:#a0a0a8;--line:#35353a;--card:#1f1f22;--accent:#8ab4f8;--accent-fg:#0b0b0c;--code:#2a2a2e}}
997*{box-sizing:border-box}
998body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,'Segoe UI',sans-serif}
999main{max-width:980px;margin:0 auto;padding:24px 16px 48px}
1000header{border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:8px}
1001.label{margin:0;color:var(--muted);font:600 14px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
1002h1{font-size:26px;line-height:1.25;margin:6px 0 16px}
1003h2{font-size:20px;margin:0 0 8px}
1004h3{font-size:17px;margin:16px 0 6px}
1005section{padding:16px 0;border-bottom:1px solid var(--line)}
1006a{color:var(--accent)}
1007a.open{display:inline-block;background:var(--accent);color:var(--accent-fg);text-decoration:none;font-weight:700;font-size:20px;padding:12px 24px;border-radius:10px}
1008a.open:hover{filter:brightness(1.08)}
1009.url{margin:8px 0 0;color:var(--muted);overflow-wrap:anywhere}
1010code{font:0.9em ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--code);padding:1px 5px;border-radius:4px}
1011pre{background:var(--code);padding:12px;border-radius:8px;overflow-x:auto}
1012pre code{padding:0;background:none}
1013ol,ul{padding-left:24px}
1014li{margin:6px 0}
1015#try-it ol>li{margin:10px 0}
1016blockquote{margin:8px 0;padding:4px 12px;border-left:3px solid var(--line);color:var(--muted)}
1017img{max-width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:var(--card)}
1018figure{margin:12px 0}
1019figcaption{color:var(--muted);font-size:14px;margin-top:4px}
1020.proof{margin:12px 0 20px}
1021.proof .state{margin:0 0 6px;font-weight:600}
1022.proof label{display:inline-block;margin:0 0 8px;color:var(--muted);font-size:14px;cursor:pointer}
1023.proof .overlay{margin-right:6px}
1024.stack{display:grid;grid-template-columns:1fr;gap:16px}
1025.stack figure{margin:0}
1026.stack img{width:100%}
1027.overlay:checked+label+.stack{gap:0}
1028.overlay:checked+label+.stack figure{grid-area:1/1}
1029.overlay:checked+label+.stack .built img{opacity:.5}
1030.overlay:checked+label+.stack .built figcaption{display:none}
1031.pair{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1032.pair figure{margin:0}
1033@media (max-width:640px){.pair{grid-template-columns:1fr}}
1034footer{margin-top:24px;color:var(--muted);font-size:14px}
1035";
1036
1037/// The alt text as written, or the role when the guide left it empty.
1038fn alt_or<'a>(alt: &'a str, role: &'a str) -> &'a str {
1039    if alt.trim().is_empty() {
1040        role
1041    } else {
1042        alt
1043    }
1044}
1045
1046/// The picked direction a guide names (`direction B`), if any.
1047fn direction(text: &str) -> Option<String> {
1048    let low = text.to_ascii_lowercase();
1049    let mut from = 0;
1050    while let Some(i) = low[from..].find("direction ") {
1051        let at = from + i + "direction ".len();
1052        let word: String = text[at..]
1053            .chars()
1054            .take_while(|c| c.is_alphanumeric())
1055            .collect();
1056        if word.chars().count() == 1 && word.chars().all(|c| c.is_ascii_alphabetic()) {
1057            return Some(word.to_ascii_uppercase());
1058        }
1059        from = at;
1060    }
1061    None
1062}
1063
1064/// A `file://` URL for an absolute path, percent-encoded. A Windows
1065/// verbatim prefix (`\\?\`) is dropped and backslashes become slashes.
1066pub fn file_url(path: &Path) -> String {
1067    let raw = path.to_string_lossy();
1068    let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
1069    let mut p = raw.replace('\\', "/");
1070    if !p.starts_with('/') {
1071        p.insert(0, '/');
1072    }
1073    let mut out = String::from("file://");
1074    for b in p.bytes() {
1075        if b.is_ascii_alphanumeric() || b"/-_.~:".contains(&b) {
1076            out.push(b as char);
1077        } else {
1078            out.push_str(&format!("%{b:02X}"));
1079        }
1080    }
1081    out
1082}
1083
1084/// Relative image sources that do not exist beside the guide.
1085pub fn missing_images(text: &str, dir: &Path) -> Vec<String> {
1086    images(text)
1087        .into_iter()
1088        .filter(|s| safe_url(s).is_some() && !s.contains(':') && !s.starts_with('/'))
1089        .filter(|s| !dir.join(s).is_file())
1090        .collect()
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    const GOOD: &str = "\
1098# ignored title
1099
1100## ๐Ÿ“ Where we are
1101duro-app, branch `feat/x`, plan **Settings**.
1102
1103## What you should see
1104The **Save** button moved.
1105
1106## ๐Ÿ‘‰ Try it
11071. Open http://localhost:5173/settings
1108   - You should see the form.
11092. Click **Save**, top right.
1110
1111## Reference
1112![before](shots/before.png) ![after](shots/after.png)
1113
1114## Already checked
1115- console clean
1116";
1117
1118    #[test]
1119    fn a_complete_guide_passes() {
1120        assert!(check(GOOD).is_empty(), "{:?}", check(GOOD));
1121    }
1122
1123    #[test]
1124    fn headings_match_without_emoji_or_case() {
1125        assert_eq!(normalise("๐Ÿ“ Where we are:"), "where we are");
1126        assert_eq!(normalise("TRY IT"), "try it");
1127    }
1128
1129    #[test]
1130    fn a_heading_with_a_trailing_note_matches() {
1131        for (heading, name) in [
1132            ("๐Ÿ‘‰ Try it (about 2 minutes)", "Try it"),
1133            ("Try it โ€” 2 min", "Try it"),
1134            ("Reference - screenshots", "Reference"),
1135            ("๐Ÿ“ Where we are: the masthead", "Where we are"),
1136        ] {
1137            assert!(names_section(heading, name), "{heading:?} names {name:?}");
1138        }
1139        let text = GOOD.replace("## ๐Ÿ‘‰ Try it\n", "## ๐Ÿ‘‰ Try it (about 2 minutes)\n");
1140        assert_ne!(text, GOOD, "the fixture has a `## ๐Ÿ‘‰ Try it` heading");
1141        let m = check(&text);
1142        assert!(!m.iter().any(|m| m.contains("Try it")), "{m:?}");
1143    }
1144
1145    const MOCKUP_GUIDE: &str = "\
1146## Where we are
1147app, branch `feat/x`, direction B.
1148
1149## What you should see
1150A pill.
1151
1152## Try it
11531. Open http://localhost:5173/
1154
1155## Reference
1156Artboards: docs/mockups/pill
1157Viewport: 1120px ยท light
1158
1159![Mockup B](artboard.png) ![Built](after.png)
1160
1161## Already checked
1162- console clean
1163
1164## Differences from the mockup
1165- fixed: the separate History segment is gone
1166- deliberate: a hidden live region copy, for screen readers
1167";
1168
1169    fn folder_with(files: &[&str]) -> std::path::PathBuf {
1170        let d = std::env::temp_dir().join(format!(
1171            "amont-agent-guide-{}-{:?}",
1172            std::process::id(),
1173            std::thread::current().id()
1174        ));
1175        let _ = std::fs::remove_dir_all(&d);
1176        std::fs::create_dir_all(&d).unwrap();
1177        for f in files {
1178            std::fs::write(d.join(f), b"x").unwrap();
1179        }
1180        d
1181    }
1182
1183    #[test]
1184    fn a_complete_mockup_guide_lacks_nothing() {
1185        let d = folder_with(&["artboard.png", "after.png"]);
1186        let m = check_mockup(MOCKUP_GUIDE, &["docs/mockups/pill".to_string()], &d);
1187        assert!(m.is_empty(), "{m:?}");
1188        assert!(check(MOCKUP_GUIDE).is_empty());
1189    }
1190
1191    #[test]
1192    fn mockup_mode_names_every_missing_piece() {
1193        let d = folder_with(&[]);
1194        let m = check_mockup(GOOD, &["docs/mockups/pill".to_string()], &d);
1195        let all = m.join("\n");
1196        for piece in [
1197            "docs/mockups/pill",
1198            "viewport line",
1199            "artboard.png",
1200            "## Differences from the mockup",
1201        ] {
1202            assert!(all.contains(piece), "{piece} in {all}");
1203        }
1204    }
1205
1206    #[test]
1207    fn a_missing_image_is_refused_even_by_absolute_path() {
1208        let d = folder_with(&["after.png"]);
1209        let text = MOCKUP_GUIDE.replace("(artboard.png)", "(/nonexistent/artboard.png)");
1210        let m = check_mockup(&text, &["docs/mockups/pill".to_string()], &d);
1211        assert!(
1212            m.iter().any(|m| m.contains("/nonexistent/artboard.png")),
1213            "{m:?}"
1214        );
1215    }
1216
1217    #[test]
1218    fn differences_must_be_classified_or_none() {
1219        assert!(differences(&["None"]).is_ok());
1220        assert!(differences(&["- fixed: spacing", "- **deliberate:** a11y copy"]).is_ok());
1221        assert!(differences(&["- spacing is off"])
1222            .unwrap_err()
1223            .contains("1 bullet"));
1224        assert!(
1225            differences(&["- deliberate:"]).is_err(),
1226            "a reason is required"
1227        );
1228        assert!(differences(&[]).is_err());
1229    }
1230
1231    #[test]
1232    fn proofs_pair_by_state_and_accept_the_aliases() {
1233        let text = "![a](shots/mockup.png) ![b](live.png) ![c](artboard-empty.png) ![d](after-empty.png) ![e](before.png)";
1234        let p = proofs(text);
1235        assert_eq!(p.len(), 2, "{p:?}");
1236        assert_eq!(
1237            (p[0].design.as_str(), p[0].built.as_str()),
1238            ("shots/mockup.png", "live.png")
1239        );
1240        assert_eq!(p[1].state, "-empty");
1241        assert_eq!(
1242            before_image(text).map(|(_, s)| s),
1243            Some("before.png".to_string())
1244        );
1245    }
1246
1247    #[test]
1248    fn the_page_shows_the_pair_full_width_with_an_overlay() {
1249        let html = render(
1250            MOCKUP_GUIDE,
1251            &Page {
1252                label: "app@abcdef0",
1253                commit: "abcdef0123",
1254                url: "http://localhost:5173/",
1255            },
1256        );
1257        assert!(
1258            html.contains("Mockup, direction B"),
1259            "caption names the direction"
1260        );
1261        assert!(html.contains("Built, <code>abcdef0</code>"));
1262        assert!(html.contains("Viewport: 1120px ยท light"));
1263        assert!(html.contains("class=\"overlay\""));
1264        assert!(
1265            html.contains("<a href=\"artboard.png\">"),
1266            "links to the full size"
1267        );
1268        assert_eq!(
1269            html.matches("src=\"artboard.png\"").count(),
1270            1,
1271            "not repeated inline"
1272        );
1273    }
1274
1275    #[test]
1276    fn a_png_width_is_read_from_its_header() {
1277        let d = folder_with(&[]);
1278        let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13];
1279        png.extend(b"IHDR");
1280        png.extend(1120u32.to_be_bytes());
1281        png.extend(800u32.to_be_bytes());
1282        std::fs::write(d.join("a.png"), &png).unwrap();
1283        assert_eq!(png_width(&d.join("a.png")), Some(1120));
1284        std::fs::write(d.join("b.png"), b"not a png at all, just text").unwrap();
1285        assert_eq!(png_width(&d.join("b.png")), None);
1286    }
1287
1288    #[test]
1289    fn extra_words_are_not_a_note() {
1290        for (heading, name) in [
1291            ("Try it now", "Try it"),
1292            ("Reference material", "Reference"),
1293            ("Try items", "Try it"),
1294            ("Already", "Already checked"),
1295        ] {
1296            assert!(!names_section(heading, name), "{heading:?} is not {name:?}");
1297        }
1298    }
1299
1300    #[test]
1301    fn a_missing_section_is_named() {
1302        let text = GOOD.replace("## Already checked", "## Checked");
1303        let m = check(&text);
1304        assert_eq!(m.len(), 1, "{m:?}");
1305        assert!(m[0].contains("Already checked"));
1306    }
1307
1308    #[test]
1309    fn a_heading_inside_a_fence_is_not_a_section() {
1310        let text = GOOD.replace(
1311            "## Already checked\n- console clean\n",
1312            "```\n## Already checked\n```\n",
1313        );
1314        assert!(check(&text).iter().any(|m| m.contains("Already checked")));
1315    }
1316
1317    #[test]
1318    fn inline_markup_is_escaped_and_unsafe_links_are_text() {
1319        let ctx = Ctx { paired: &[] };
1320        assert_eq!(
1321            inline("<script>x</script>", &ctx),
1322            "&lt;script&gt;x&lt;/script&gt;"
1323        );
1324        assert!(!inline("[x](javascript:alert(1))", &ctx).contains("<a"));
1325        assert_eq!(
1326            inline("see http://localhost:1/a.", &ctx),
1327            "see <a href=\"http://localhost:1/a\">http://localhost:1/a</a>."
1328        );
1329        assert_eq!(
1330            inline("**b** `c<`", &ctx),
1331            "<strong>b</strong> <code>c&lt;</code>"
1332        );
1333    }
1334
1335    #[test]
1336    fn steps_nest_their_expectations() {
1337        let html = render(
1338            GOOD,
1339            &Page {
1340                label: "app@abc1234",
1341                commit: "abc",
1342                url: "http://localhost:5173/",
1343            },
1344        );
1345        assert!(html.contains("<ol>\n<li>Open"), "{html}");
1346        assert!(
1347            html.contains("<ul>\n<li>You should see the form."),
1348            "{html}"
1349        );
1350        assert!(html.contains("<div class=\"pair\">"), "{html}");
1351        assert!(
1352            html.contains("<title>app@abc1234 โ€” duro-app, branch feat/x, plan Settings.</title>"),
1353            "{html}"
1354        );
1355    }
1356
1357    #[test]
1358    fn file_urls_are_encoded() {
1359        assert_eq!(file_url(Path::new("/a b/c.html")), "file:///a%20b/c.html");
1360        assert_eq!(
1361            file_url(Path::new(r"\\?\C:\x\i.html")),
1362            "file:///C:/x/i.html"
1363        );
1364    }
1365}