Skip to main content

speccoder_runtime/attest/
check.rs

1//! The `speccoder / evidence` check run, rendered from a branch report:
2//! the verdict bar and the trust it used (summary), the requirement trace,
3//! every tick and its tour (text), and one annotation per tick that is not
4//! plainly verified, on the line it is about. The same, as workflow
5//! commands and a step summary, where the check cannot be published.
6//!
7//! Rendering only: the report decides, these words never soften it.
8
9use serde::Serialize;
10use serde_json::{Value, json};
11use speccoder_core::verify_branch::{
12    Anchor, BranchReport, Conclusion, EARLIER_TASK_REGRESSIONS, TRAILER_ONLY_TASKS, TickOutcome,
13    TickReport, Unverifiable,
14};
15use speccoder_core::workflow::{ANNOTATIONS_PER_KIND, data, property};
16
17use crate::evidence::banner;
18
19/// The check run's name.
20pub const NAME: &str = "speccoder / evidence";
21
22/// Characters GitHub takes in a check run's `summary`, and in its `text`.
23pub const TEXT_BUDGET: usize = 65_535;
24
25/// Annotations GitHub takes per check-run request.
26pub const ANNOTATIONS_PER_REQUEST: usize = 50;
27
28/// One annotation, in GitHub's check-run shape.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct Annotation {
31    /// Repository-relative path.
32    pub path: String,
33    /// First line.
34    pub start_line: u32,
35    /// Last line.
36    pub end_line: u32,
37    /// `failure`, `warning` or `notice`.
38    pub annotation_level: &'static str,
39    /// One line.
40    pub title: String,
41    /// What it means and what to do.
42    pub message: String,
43}
44
45fn at(anchor: &Anchor, level: &'static str, title: String, message: String) -> Annotation {
46    Annotation {
47        path: anchor.path.clone(),
48        start_line: anchor.line.max(1),
49        end_line: anchor.line.max(1),
50        annotation_level: level,
51        title,
52        message,
53    }
54}
55
56/// What to do about an unverifiable tick.
57fn fix(r: &TickReport, reason: Unverifiable) -> String {
58    let task = &r.tick.task;
59    match reason {
60        Unverifiable::TickedWithoutEvidence => format!(
61            "No commit carries this task's evidence. Re-run the task with speccoder, or `speccoder task accept {task}` (attested)."
62        ),
63        Unverifiable::Missing => "The task commit has no attestation note: push them (`git push origin refs/notes/speccoder`).".to_owned(),
64        Unverifiable::CommitUnreachable => "The commit with the evidence is not on this branch: a squash or rebase merge drops it; merge with a merge commit.".to_owned(),
65        Unverifiable::UntrustedSigner => "Signed by a key the maintainer's trust does not list: add the signer to the base branch's allowed signers, or re-sign.".to_owned(),
66        Unverifiable::Unsigned => "The evidence is not signed: `speccoder attest sign`, then push the notes.".to_owned(),
67        Unverifiable::ToolMissing => "The signature's tool is not installed on this runner; install it and re-run.".to_owned(),
68        Unverifiable::BadSignature | Unverifiable::Mismatch | Unverifiable::Malformed => {
69            "The evidence does not hold for this commit (edited or tampered). Re-run the task with speccoder.".to_owned()
70        }
71    }
72}
73
74/// One annotation per tick that is not plainly verified: unverifiable and
75/// regressed as `failure`, regression-only and stale as `warning`
76/// (stale on the changed requirement's line), attested as a `notice`
77/// naming who accepted it.
78#[must_use]
79pub fn annotations(report: &BranchReport) -> Vec<Annotation> {
80    let mut out = Vec::new();
81    for r in &report.ticks {
82        let task = &r.tick.task;
83        let detail = r
84            .detail
85            .as_deref()
86            .map(|d| format!(" ({d})"))
87            .unwrap_or_default();
88        match (r.outcome, r.verdict.as_deref()) {
89            (TickOutcome::Unverifiable, _) => {
90                let reason = r.reason.unwrap_or(Unverifiable::Malformed);
91                out.push(at(
92                    &r.tick.anchor,
93                    "failure",
94                    format!("{task} unverifiable: {}", reason.word()),
95                    format!("{}{detail}", fix(r, reason)),
96                ));
97            }
98            (TickOutcome::Regressed, _) => out.push(at(
99                &r.tick.anchor,
100                "failure",
101                format!("{task} regressed"),
102                "A later task's signed checkpoint marks this task regressed.".to_owned(),
103            )),
104            (TickOutcome::Stale, _) => out.push(at(
105                r.stale_anchor.as_ref().unwrap_or(&r.tick.anchor),
106                "warning",
107                format!(
108                    "{task} stale: {} changed",
109                    r.stale
110                        .iter()
111                        .map(|d| d.id.as_str())
112                        .collect::<Vec<_>>()
113                        .join(", ")
114                ),
115                format!(
116                    "{task}'s evidence is for the earlier wording of this requirement. Re-prove it (speccoder converge) or revert the edit."
117                ),
118            )),
119            (TickOutcome::Evidenced, Some("regression_only")) => out.push(at(
120                &r.tick.anchor,
121                "warning",
122                format!("{task} regression only"),
123                banner("regression_only").1.to_owned(),
124            )),
125            (TickOutcome::Evidenced, Some("attested")) => out.push(at(
126                &r.tick.anchor,
127                "notice",
128                format!(
129                    "{task} attested by {}",
130                    r.decider.as_deref().unwrap_or("a person")
131                ),
132                banner("attested").1.to_owned(),
133            )),
134            (TickOutcome::Evidenced, _) => {}
135        }
136    }
137    out
138}
139
140/// The conclusion as a sentence.
141fn sentence(report: &BranchReport) -> String {
142    let failing = report
143        .ticks
144        .iter()
145        .filter(|t| t.fails(report.require_verified))
146        .count();
147    match report.conclusion {
148        Conclusion::Success => "every tick carries evidence that verifies.".to_owned(),
149        Conclusion::Neutral => "neutral: no ticks checked — nothing here is proof.".to_owned(),
150        Conclusion::Failure if failing == 0 => {
151            "a claim in range cannot be checked by this verifier version.".to_owned()
152        }
153        Conclusion::Failure => format!("{failing} tick(s) without verifying evidence."),
154    }
155}
156
157/// The verdict bar, its first line: the counts in the verdict words.
158#[must_use]
159pub fn bar(report: &BranchReport) -> String {
160    let t = &report.totals;
161    let mut bar = format!(
162        "**verified {} · regression only {} · attested {} · stale {} · unverifiable {}",
163        t.verified, t.regression_only, t.attested, t.stale, t.unverifiable
164    );
165    if t.regressed > 0 {
166        bar.push_str(&format!(" · regressed {}", t.regressed));
167    }
168    format!("{bar}** — {}", sentence(report))
169}
170
171fn trust_line(report: &BranchReport) -> String {
172    let ssh = &report.trust["ssh_allowed_signers"];
173    let ssh = match (
174        ssh["rev"].as_str(),
175        ssh["path"].as_str(),
176        ssh["file"].as_str(),
177    ) {
178        (Some(rev), Some(path), _) => format!("allowed signers `{path}` at `{rev}`"),
179        (_, _, Some(file)) => format!("allowed signers `{file}`"),
180        _ => "no SSH allowed signers".to_owned(),
181    };
182    let sigstore = report.trust["sigstore"]["identity"]
183        .as_str()
184        .map(|i| {
185            format!(
186                ", Sigstore identity `{i}` ({})",
187                report.trust["sigstore"]["issuer"].as_str().unwrap_or("?")
188            )
189        })
190        .unwrap_or_default();
191    format!("Trust: {ssh}{sigstore}; the verifier's, never the branch's.")
192}
193
194/// The check run's `summary`: the bar, the trust, and what this verifier
195/// did not evaluate or show.
196#[must_use]
197pub fn summary(report: &BranchReport, omitted_annotations: usize) -> String {
198    let mut out = format!("{}\n\n{}\n", bar(report), trust_line(report));
199    if report.accepted_unsigned {
200        out.push_str(
201            "\n> **Unsigned ticks were accepted** (`require-signed: false`): their statements could have been written by anyone who can push.\n",
202        );
203    }
204    if report.require_verified {
205        out.push_str("\nEvery tick must be `verified` (`require-verified: true`).\n");
206    }
207    let mut notes = Vec::new();
208    for n in &report.not_evaluated {
209        notes.push(match n.as_str() {
210            EARLIER_TASK_REGRESSIONS => {
211                "regressions of earlier tasks are not checked by this verifier version".to_owned()
212            }
213            TRAILER_ONLY_TASKS => "a trailer-only bug task (`Spec-Task: <unit>/bug:<slug>`) is in range; this verifier version cannot check it, so the branch fails".to_owned(),
214            other => format!("{other}: not checked by this verifier version"),
215        });
216    }
217    if !report.newer_fields.is_empty() {
218        notes.push(format!(
219            "fields from a newer predicate revision not shown: {}",
220            report.newer_fields.join(", ")
221        ));
222    }
223    if !report.open_no_claim.is_empty() {
224        notes.push(format!(
225            "open, no claim: {}",
226            report.open_no_claim.join(", ")
227        ));
228    }
229    if !report.skipped.is_empty() {
230        notes.push(format!(
231            "{} task(s) skipped by a person (not ticks)",
232            report.skipped.len()
233        ));
234    }
235    if omitted_annotations > 0 {
236        notes.push(format!(
237            "{omitted_annotations} annotation(s) not shown — every tick is in the table"
238        ));
239    }
240    for n in notes {
241        out.push_str(&format!("\n- {n}"));
242    }
243    if !out.ends_with('\n') {
244        out.push('\n');
245    }
246    cut(&out, TEXT_BUDGET, "the summary")
247}
248
249/// A table cell: one line, no pipes.
250fn cell(s: &str) -> String {
251    s.replace('|', "\\|").replace('\n', " ")
252}
253
254fn verdict_words(r: &TickReport) -> String {
255    match r.outcome {
256        TickOutcome::Evidenced => banner(r.verdict.as_deref().unwrap_or_default())
257            .1
258            .to_owned(),
259        TickOutcome::Stale => "STALE — evidence for an earlier wording".to_owned(),
260        TickOutcome::Unverifiable => format!(
261            "UNVERIFIABLE — {}",
262            r.reason.map_or("no evidence", Unverifiable::word)
263        ),
264        TickOutcome::Regressed => "REGRESSED".to_owned(),
265    }
266}
267
268/// The per-tick table and the trace, without tours.
269fn tables(report: &BranchReport) -> String {
270    let mut out = String::from(
271        "### Ticks\n\n| Unit | Task | Outcome | Commit | Signer |\n|---|---|---|---|---|\n",
272    );
273    for r in &report.ticks {
274        out.push_str(&format!(
275            "| {} | {} | {}{} | {} | {} |\n",
276            cell(&r.tick.feature),
277            cell(&r.tick.task),
278            cell(&verdict_words(r)),
279            r.detail
280                .as_deref()
281                .map(|d| format!(" — {}", cell(d)))
282                .unwrap_or_default(),
283            r.commit.as_deref().map_or_else(
284                || "—".to_owned(),
285                |c| format!("`{}`", &c[..c.len().min(12)])
286            ),
287            r.signer.as_ref().map_or_else(
288                || "—".to_owned(),
289                |s| cell(s.principal.as_deref().unwrap_or(&s.kind))
290            ),
291        ));
292    }
293    if report.ticks.is_empty() {
294        out.push_str("| — | — | no ticks | — | — |\n");
295    }
296    if !report.trace.is_empty() {
297        out.push_str("\n### Requirement trace\n\n| Requirement | Scenario | Test | Task | Verdict |\n|---|---|---|---|---|\n");
298        for t in &report.trace {
299            out.push_str(&format!(
300                "| {} | {} | {} | {} | {} |\n",
301                cell(&t.requirement),
302                cell(t.scenario.as_deref().unwrap_or("—")),
303                cell(t.test.as_deref().unwrap_or("—")),
304                cell(t.task.as_deref().unwrap_or("not cited")),
305                cell(&match (&t.verdict, t.changed) {
306                    (Some(v), true) => format!("{v} (changed since)"),
307                    (Some(v), false) => v.clone(),
308                    (None, _) => "—".to_owned(),
309                }),
310            ));
311        }
312    }
313    out
314}
315
316/// The check run's `text`: the tables, then each tour collapsed, tours
317/// dropped last-first with a counted line when over budget. Returns how
318/// many tours were not shown.
319#[must_use]
320pub fn text(report: &BranchReport, tours: &[String]) -> (String, usize) {
321    let tables = cut(&tables(report), TEXT_BUDGET - 200, "the tables");
322    let mut out = tables;
323    let mut shown = 0;
324    let budget = TEXT_BUDGET - 200;
325    if !tours.is_empty() {
326        out.push_str("\n### Evidence tours\n\n");
327    }
328    for tour in tours {
329        if out.chars().count() + tour.chars().count() + 1 > budget {
330            break;
331        }
332        out.push_str(tour);
333        out.push('\n');
334        shown += 1;
335    }
336    let omitted = tours.len() - shown;
337    if omitted > 0 {
338        out.push_str(&format!(
339            "\n{omitted} tour(s) not shown — run `speccoder-verify tour --feature <unit> --task <id>`\n"
340        ));
341    }
342    (out, omitted)
343}
344
345/// `text` within `budget` characters, cut at a line with a counted note.
346fn cut(text: &str, budget: usize, what: &str) -> String {
347    if text.chars().count() <= budget {
348        return text.to_owned();
349    }
350    let mut out = String::new();
351    let mut lines = text.lines();
352    let mut dropped = 0;
353    for line in lines.by_ref() {
354        if out.chars().count() + line.chars().count() + 120 > budget {
355            dropped += 1;
356            break;
357        }
358        out.push_str(line);
359        out.push('\n');
360    }
361    dropped += lines.count();
362    out.push_str(&format!(
363        "\n… {dropped} line(s) of {what} not shown (GitHub's size cap); every tick is in the outcome.\n"
364    ));
365    out
366}
367
368/// The conclusion a check run is published with: GitHub counts a
369/// `neutral` required check as passing, so neutral is `failure` unless the
370/// maintainer allows it.
371#[must_use]
372pub fn published(conclusion: Conclusion, allow_neutral: bool) -> &'static str {
373    match conclusion {
374        Conclusion::Neutral if allow_neutral => "neutral",
375        Conclusion::Neutral | Conclusion::Failure => "failure",
376        Conclusion::Success => "success",
377    }
378}
379
380/// The check-run request bodies, `{ "create": …, "updates": [ … ] }`:
381/// at most 50 annotations in the create, the rest in updates of 50.
382#[must_use]
383pub fn request(report: &BranchReport, tours: &[String], name: &str, allow_neutral: bool) -> Value {
384    let all = annotations(report);
385    let summary = summary(report, 0);
386    let (text, _) = text(report, tours);
387    let title = report.title();
388    let mut batches = all.chunks(ANNOTATIONS_PER_REQUEST);
389    let first: Vec<&Annotation> = batches
390        .next()
391        .map(|b| b.iter().collect())
392        .unwrap_or_default();
393    let updates: Vec<Value> = batches
394        .map(|b| {
395            json!({
396                "output": { "title": title, "summary": summary, "annotations": b }
397            })
398        })
399        .collect();
400    json!({
401        "create": {
402            "name": name,
403            "head_sha": report.head,
404            "status": "completed",
405            "conclusion": published(report.conclusion, allow_neutral),
406            "output": {
407                "title": title,
408                "summary": summary,
409                "text": text,
410                "annotations": first,
411            }
412        },
413        "updates": updates,
414    })
415}
416
417/// The fallback: the annotations as workflow commands, at most 10 of a
418/// kind, then a notice counting the rest.
419#[must_use]
420pub fn workflow_commands(report: &BranchReport) -> Vec<String> {
421    let mut by_kind: [(&str, Vec<String>); 3] = [
422        ("error", Vec::new()),
423        ("warning", Vec::new()),
424        ("notice", Vec::new()),
425    ];
426    for a in annotations(report) {
427        let kind = match a.annotation_level {
428            "failure" => 0,
429            "warning" => 1,
430            _ => 2,
431        };
432        by_kind[kind].1.push(format!(
433            "::{} file={},line={},title={}::{}",
434            by_kind[kind].0,
435            property(&a.path),
436            a.start_line,
437            property(&a.title),
438            data(&a.message)
439        ));
440    }
441    let hidden: usize = by_kind
442        .iter()
443        .map(|(_, v)| v.len().saturating_sub(ANNOTATIONS_PER_KIND))
444        .sum();
445    let mut out: Vec<String> = by_kind
446        .into_iter()
447        .flat_map(|(_, v)| v.into_iter().take(ANNOTATIONS_PER_KIND))
448        .collect();
449    if hidden > 0 {
450        out.push(format!(
451            "::notice title={}::{}",
452            property(NAME),
453            data(&format!(
454                "{hidden} more annotation(s) not shown — every tick is in the job summary"
455            ))
456        ));
457    }
458    out
459}
460
461/// The whole check as Markdown: summary, then text (the job summary, the
462/// `--markdown` file).
463#[must_use]
464pub fn markdown(report: &BranchReport, tours: &[String]) -> String {
465    let (text, _) = text(report, tours);
466    format!(
467        "## {NAME}: {}\n\n{}\n{text}",
468        report.title(),
469        summary(report, 0)
470    )
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use speccoder_core::RequirementDrift;
477    use speccoder_core::verify_branch::{BranchTotals, Mode, Tick, TickKind, share};
478
479    fn tick(task: &str, line: u32, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
480        TickReport {
481            tick: Tick {
482                feature: "001-x".to_owned(),
483                task: task.to_owned(),
484                title: format!("{task} title"),
485                kind: TickKind::Tick,
486                anchor: Anchor {
487                    path: "specs/001-x/tasks.md".to_owned(),
488                    line,
489                },
490                line_sha256: None,
491                claim_sha256: None,
492            },
493            commit: Some("0123456789abcdef".to_owned()),
494            outcome,
495            verdict: verdict.map(str::to_owned),
496            reason: (outcome == TickOutcome::Unverifiable)
497                .then_some(Unverifiable::TickedWithoutEvidence),
498            detail: None,
499            signer: None,
500            decider: (verdict == Some("attested")).then(|| "Ada".to_owned()),
501            stale: if outcome == TickOutcome::Stale {
502                vec![RequirementDrift {
503                    id: "FR-002".to_owned(),
504                    then: Some("old".to_owned()),
505                    now: Some("new".to_owned()),
506                }]
507            } else {
508                Vec::new()
509            },
510            stale_anchor: (outcome == TickOutcome::Stale).then(|| Anchor {
511                path: "specs/001-x/spec.md".to_owned(),
512                line: 9,
513            }),
514            newer_fields: Vec::new(),
515            verification: None,
516        }
517    }
518
519    fn report(ticks: Vec<TickReport>) -> BranchReport {
520        let conclusion = Conclusion::of(&ticks, false, &[]);
521        BranchReport {
522            head: "h".to_owned(),
523            base: None,
524            mode: Mode::All,
525            features: vec!["001-x".to_owned()],
526            totals: BranchTotals::of(&ticks, 0),
527            share: share(&ticks),
528            ticks,
529            skipped: Vec::new(),
530            conclusion,
531            trust: json!({ "ssh_allowed_signers": { "rev": "base", "path": ".github/speccoder/allowed_signers" } }),
532            accepted_unsigned: false,
533            require_verified: false,
534            trace: Vec::new(),
535            open_no_claim: Vec::new(),
536            not_evaluated: vec![EARLIER_TASK_REGRESSIONS.to_owned()],
537            newer_fields: vec!["provenance".to_owned()],
538        }
539    }
540
541    fn mixed() -> BranchReport {
542        use TickOutcome::*;
543        let mut r = report(vec![
544            tick("T1", 3, Evidenced, Some("verified")),
545            tick("T2", 4, Evidenced, Some("regression_only")),
546            tick("T3", 5, Evidenced, Some("attested")),
547            tick("T4", 6, Stale, Some("verified")),
548            tick("T5", 7, Unverifiable, None),
549            tick("T6", 8, Regressed, Some("verified")),
550        ]);
551        r.ticks[2].decider = Some("Ada".to_owned());
552        r
553    }
554
555    #[test]
556    fn one_annotation_per_tick_that_is_not_plainly_verified_at_its_level_and_line() {
557        let r = mixed();
558        let a = annotations(&r);
559        let got: Vec<(&str, &str, u32)> = a
560            .iter()
561            .map(|a| (a.annotation_level, a.path.as_str(), a.start_line))
562            .collect();
563        assert_eq!(
564            got,
565            [
566                ("warning", "specs/001-x/tasks.md", 4),
567                ("notice", "specs/001-x/tasks.md", 5),
568                ("warning", "specs/001-x/spec.md", 9),
569                ("failure", "specs/001-x/tasks.md", 7),
570                ("failure", "specs/001-x/tasks.md", 8),
571            ]
572        );
573        assert_eq!(a[1].title, "T3 attested by Ada");
574        assert_eq!(a[2].title, "T4 stale: FR-002 changed");
575        assert_eq!(a[3].title, "T5 unverifiable: ticked_without_evidence");
576    }
577
578    #[test]
579    fn the_summary_leads_with_the_bar_and_names_what_is_not_evaluated() {
580        let r = mixed();
581        let s = summary(&r, 0);
582        assert!(
583            s.starts_with("**verified 1 · regression only 1 · attested 1 · stale 1 · unverifiable 1 · regressed 1** — 3 tick(s) without verifying evidence."),
584            "{s}"
585        );
586        assert!(
587            s.contains("regressions of earlier tasks are not checked by this verifier version")
588        );
589        assert!(s.contains("fields from a newer predicate revision not shown: provenance"));
590        assert!(s.contains("`.github/speccoder/allowed_signers` at `base`"));
591        assert_eq!(
592            r.title(),
593            "failure: 1 verified, 1 regression only, 1 attested, 1 stale, 1 unverifiable, 1 regressed"
594        );
595    }
596
597    #[test]
598    fn sixty_annotations_go_fifty_in_the_create_and_ten_in_an_update() {
599        let ticks: Vec<TickReport> = (1..=60)
600            .map(|i| tick(&format!("T{i}"), i, TickOutcome::Unverifiable, None))
601            .collect();
602        let r = report(ticks);
603        let req = request(&r, &[], NAME, false);
604        assert_eq!(
605            req["create"]["output"]["annotations"]
606                .as_array()
607                .unwrap()
608                .len(),
609            50
610        );
611        assert_eq!(req["updates"].as_array().unwrap().len(), 1);
612        assert_eq!(
613            req["updates"][0]["output"]["annotations"]
614                .as_array()
615                .unwrap()
616                .len(),
617            10
618        );
619        assert_eq!(req["create"]["head_sha"], "h");
620        assert_eq!(req["create"]["conclusion"], "failure");
621        // The fallback: ten per kind, the rest counted.
622        let lines = workflow_commands(&r);
623        assert_eq!(lines.len(), 11);
624        assert!(
625            lines[10].contains("50 more annotation(s) not shown"),
626            "{}",
627            lines[10]
628        );
629        assert!(lines[0].starts_with("::error file=specs/001-x/tasks.md,line=1,title=T1 unverifiable%3A ticked_without_evidence::"));
630    }
631
632    #[test]
633    fn neutral_is_published_as_failure_unless_allowed() {
634        let r = report(Vec::new());
635        assert_eq!(r.conclusion, Conclusion::Neutral);
636        assert_eq!(
637            request(&r, &[], NAME, false)["create"]["conclusion"],
638            "failure"
639        );
640        assert_eq!(
641            request(&r, &[], NAME, true)["create"]["conclusion"],
642            "neutral"
643        );
644        assert_eq!(
645            request(&r, &[], NAME, false)["create"]["output"]["title"],
646            "neutral: no ticks checked"
647        );
648    }
649
650    #[test]
651    fn summary_and_text_stay_within_the_cap_and_count_what_was_cut() {
652        let ticks: Vec<TickReport> = (1..=400)
653            .map(|i| tick(&format!("T{i}"), i, TickOutcome::Unverifiable, None))
654            .collect();
655        let r = report(ticks);
656        let tours: Vec<String> = (0..400)
657            .map(|i| format!("<details>{}</details>\n", "x".repeat(400 + i)))
658            .collect();
659        let (t, omitted) = text(&r, &tours);
660        assert!(t.chars().count() <= TEXT_BUDGET);
661        assert!(omitted > 0);
662        assert!(t.contains(&format!("{omitted} tour(s) not shown")));
663        let s = summary(&r, 350);
664        assert!(s.chars().count() <= TEXT_BUDGET);
665        assert!(s.contains("350 annotation(s) not shown"));
666        let long = "line\n".repeat(20_000);
667        let c = cut(&long, 1000, "x");
668        assert!(c.chars().count() <= 1000, "{}", c.chars().count());
669        assert!(c.contains("line(s) of x not shown"));
670    }
671}