Skip to main content

ytcli/render/
wiki.rs

1//! Yandex Wiki pages.
2
3use std::fmt::Write as _;
4
5use crate::api::wiki::{
6    CursorPage, WikiAttachment, WikiComment, WikiGrid, WikiGridRef, WikiHits, WikiPage,
7    WikiPageRef, WikiResource,
8};
9use crate::render::Context;
10use crate::render::style::Palette;
11use crate::render::table::{Column, cursor_tally, open_page_tally, render};
12
13/// Search hits: where each one is, what kind, when it last changed, and its
14/// title — dimmed, because somebody else wrote it.
15///
16/// The excerpt the Wiki sends with each hit is left to `--format json`: it is
17/// somebody else's words, cut mid-sentence, and `wiki get` reads the real
18/// thing for one request.
19#[must_use]
20pub fn hits(found: &WikiHits, ctx: &Context) -> String {
21    let columns = [
22        Column::whole("SLUG", 44, Palette::key()),
23        Column::new("TYPE", 5, anstyle::Style::new()),
24        Column::new("MODIFIED", 10, Palette::label()),
25        Column::new("TITLE", 50, Palette::untrusted()),
26    ];
27    let rows: Vec<Vec<String>> = found
28        .results
29        .iter()
30        .map(|hit| {
31            vec![
32                hit.slug.clone(),
33                hit.kind.clone(),
34                // The date alone: the time of a last change is noise in a list.
35                hit.modified_at
36                    .as_deref()
37                    .map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect()),
38                hit.title.clone(),
39            ]
40        })
41        .collect();
42
43    let mut out = render(&columns, &rows, ctx);
44    out.push_str(&open_page_tally(found.results.len(), found.next_page, ctx));
45    out
46}
47
48/// A page's comments, each fenced as somebody else's words.
49///
50/// The header line is ours: who, when, whether it is resolved, and — when a
51/// thread holds more than the one post — the flag that reads the rest. A
52/// deleted comment keeps its header, so the thread around it still makes
53/// sense, and loses its fence, since there is nothing left to quote.
54#[must_use]
55pub fn comments(slug: &str, list: &CursorPage<WikiComment>, ctx: &Context) -> String {
56    let mut out = String::with_capacity(list.results.len() * 200 + 64);
57    let paint = ctx.painter();
58
59    for comment in &list.results {
60        let author = comment.author.as_deref().unwrap_or("-");
61        let mut header = format!(
62            "--- {} by {author} at {}",
63            comment.id,
64            comment.created_at.as_deref().unwrap_or("-")
65        );
66        if comment.resolved {
67            header.push_str(" (resolved)");
68        }
69        if comment.deleted {
70            header.push_str(" (deleted)");
71        }
72        if let Some(posts) = comment.thread_posts.filter(|posts| *posts > 1) {
73            let _ = write!(header, " — {posts} in thread: --thread {}", comment.id);
74        }
75        let _ = writeln!(out, "{}", paint.paint(&header, Palette::label()));
76
77        if comment.deleted || comment.body.is_empty() {
78            continue;
79        }
80        crate::render::text::quoted_block(
81            &mut out,
82            &format!("wiki:{slug}/comment/{} by {author}", comment.id),
83            crate::render::untrusted::Author::Wiki,
84            &comment.body,
85            0,
86            ctx,
87        );
88    }
89
90    out.push_str(&cursor_tally(
91        list.results.len(),
92        list.next_cursor.as_deref(),
93        ctx,
94    ));
95    out
96}
97
98/// The Wiki's size string with its unit: `0.29` is `0.29 MB`, `0.00` is
99/// `<0.01 MB`, and anything that is not a number is left as it came.
100fn megabytes(size: &str) -> String {
101    match size.parse::<f64>() {
102        Ok(mb) if mb > 0.0 => format!("{size} MB"),
103        Ok(_) => "<0.01 MB".to_owned(),
104        Err(_) => size.to_owned(),
105    }
106}
107
108/// A page's attachments.
109///
110/// The name was chosen by whoever uploaded it, so it gets the styling of
111/// somebody else's text, as Tracker's attachment names do. The Wiki sends the
112/// size as a string of megabytes to two places, which a live upload settled
113/// (300 KB came back as `0.29`); the unit is written out, and a file under
114/// five kilobytes, which the Wiki rounds to `0.00`, is said to be small rather
115/// than empty.
116#[must_use]
117pub fn attachments(list: &CursorPage<WikiAttachment>, ctx: &Context) -> String {
118    let columns = [
119        Column::whole("ID", 10, Palette::key()),
120        Column::whole("SIZE", 10, anstyle::Style::new()),
121        Column::new("TYPE", 18, anstyle::Style::new()),
122        Column::new("CREATED", 10, Palette::label()),
123        Column::whole("NAME", 40, Palette::untrusted()),
124    ];
125    let rows: Vec<Vec<String>> = list
126        .results
127        .iter()
128        .map(|file| {
129            vec![
130                file.id.to_string(),
131                megabytes(&file.size),
132                file.mimetype.as_deref().unwrap_or("-").to_owned(),
133                file.created_at
134                    .as_deref()
135                    .map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect()),
136                file.name.clone(),
137            ]
138        })
139        .collect();
140
141    let mut out = render(&columns, &rows, ctx);
142    out.push_str(&cursor_tally(
143        list.results.len(),
144        list.next_cursor.as_deref(),
145        ctx,
146    ));
147    out
148}
149
150/// One grid: what it is and its columns, then its rows as somebody else's text.
151///
152/// The rows go out tab-separated inside the fence: every cell is somebody
153/// else's words, a tab-separated block is what a pipe and an agent read with
154/// the least overhead, and the fence has to hold the whole table rather than a
155/// cell at a time. A tab, a newline or a backslash inside a cell is written
156/// `\t`, `\n`, `\\`, so every row stays one line and the escape can be undone.
157/// A person gets the same block kept verbatim instead of reflowed as prose.
158/// Rows are cut like a description, and `--full` shows them all.
159#[must_use]
160pub fn grid(grid: &WikiGrid, ctx: &Context) -> String {
161    let paint = ctx.painter();
162    let label = |text: &str| paint.paint(text, Palette::label());
163    let mut out = String::with_capacity(256 + grid.rows.len() * 64);
164
165    let _ = writeln!(
166        out,
167        "{}  {}",
168        paint.paint(&grid.id, Palette::key()),
169        grid.title
170    );
171    let _ = writeln!(
172        out,
173        "{} {}   {} {}",
174        label("page:"),
175        grid.page.as_ref().map_or("-", |page| page.slug.as_str()),
176        label("revision:"),
177        if grid.revision.is_empty() {
178            "-"
179        } else {
180            &grid.revision
181        },
182    );
183    let columns: Vec<String> = grid
184        .columns
185        .iter()
186        .map(|column| format!("{}:{}", column.slug, column.kind))
187        .collect();
188    let _ = writeln!(out, "{} {}", label("columns:"), columns.join(" "));
189
190    let mut table = String::with_capacity(grid.rows.len() * 64);
191    let titles: Vec<String> = grid
192        .columns
193        .iter()
194        .map(|column| tsv_cell(&column.title))
195        .collect();
196    let _ = writeln!(table, "{}", titles.join("\t"));
197    for row in &grid.rows {
198        let cells: Vec<String> = row
199            .cells
200            .iter()
201            .map(|value| tsv_cell(&cell_text(value)))
202            .collect();
203        let _ = writeln!(table, "{}", cells.join("\t"));
204    }
205
206    let (body, withheld) = crate::render::untrusted::head(&table, ctx.description_lines);
207    let body = if ctx.is_human() {
208        format!("```\n{}\n```", body.trim_end())
209    } else {
210        body
211    };
212    crate::render::text::quoted_block(
213        &mut out,
214        &format!("wiki:grid/{}", grid.id),
215        crate::render::untrusted::Author::Wiki,
216        &body,
217        withheld,
218        ctx,
219    );
220
221    let shown = grid.rows.len().saturating_sub(withheld);
222    let _ = writeln!(
223        out,
224        "{}",
225        label(&format!("shown {shown} of {}", grid.rows.len()))
226    );
227    out
228}
229
230/// A cell as a person reads it: a user by login, a ticket by key, a Tracker
231/// field by what it displays, a list joined. The typed value is in JSON.
232fn cell_text(value: &serde_json::Value) -> String {
233    use serde_json::Value;
234
235    match value {
236        Value::Null => String::new(),
237        Value::String(text) => text.clone(),
238        Value::Array(items) => items.iter().map(cell_text).collect::<Vec<_>>().join(", "),
239        Value::Object(fields) => ["username", "display", "key"]
240            .iter()
241            .find_map(|name| fields.get(*name).and_then(Value::as_str))
242            .map_or_else(|| value.to_string(), str::to_owned),
243        other => other.to_string(),
244    }
245}
246
247/// Keep a cell on its line: the escape is reversible, so nothing is lost.
248fn tsv_cell(text: &str) -> String {
249    let mut cell = String::with_capacity(text.len());
250    for character in text.chars() {
251        match character {
252            '\\' => cell.push_str("\\\\"),
253            '\t' => cell.push_str("\\t"),
254            '\n' => cell.push_str("\\n"),
255            '\r' => cell.push_str("\\r"),
256            other => cell.push(other),
257        }
258    }
259    cell
260}
261
262/// The grids on a page: the id `wiki grid` takes, then when and what.
263#[must_use]
264pub fn grids(list: &CursorPage<WikiGridRef>, ctx: &Context) -> String {
265    let columns = [
266        Column::whole("ID", 36, Palette::key()),
267        Column::new("CREATED", 10, Palette::label()),
268        Column::new("TITLE", 50, Palette::untrusted()),
269    ];
270    let rows: Vec<Vec<String>> = list
271        .results
272        .iter()
273        .map(|grid| {
274            vec![
275                grid.id.clone(),
276                day(grid.created_at.as_deref()),
277                grid.title.clone(),
278            ]
279        })
280        .collect();
281
282    let mut out = render(&columns, &rows, ctx);
283    out.push_str(&cursor_tally(
284        list.results.len(),
285        list.next_cursor.as_deref(),
286        ctx,
287    ));
288    out
289}
290
291/// What a page holds, files and grids in one list.
292#[must_use]
293pub fn resources(list: &CursorPage<WikiResource>, ctx: &Context) -> String {
294    let columns = [
295        Column::new("TYPE", 10, anstyle::Style::new()),
296        Column::whole("ID", 36, Palette::key()),
297        Column::new("CREATED", 10, Palette::label()),
298        Column::whole("NAME", 40, Palette::untrusted()),
299    ];
300    let rows: Vec<Vec<String>> = list
301        .results
302        .iter()
303        .map(|resource| {
304            vec![
305                resource.kind.clone(),
306                resource.id.clone(),
307                day(resource.created_at.as_deref()),
308                resource.name.clone(),
309            ]
310        })
311        .collect();
312
313    let mut out = render(&columns, &rows, ctx);
314    out.push_str(&cursor_tally(
315        list.results.len(),
316        list.next_cursor.as_deref(),
317        ctx,
318    ));
319    out
320}
321
322/// The date alone: the time of day is noise in a list.
323fn day(at: Option<&str>) -> String {
324    at.map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect())
325}
326
327/// Who can read and edit a page: the policy, then every grant.
328///
329/// Each grant says which of the Wiki's three lists it came from, because
330/// "why can this person see it" is answered by the list, not the role. A
331/// group's name was chosen by somebody else and is styled as such.
332#[must_use]
333pub fn access(access: &crate::api::wiki::WikiAccess, ctx: &Context) -> String {
334    let paint = ctx.painter();
335    let label = |text: &str| paint.paint(text, Palette::label());
336    let mut out = String::with_capacity(128 + access.entries.len() * 80);
337
338    let policy = access.policy.as_deref().unwrap_or("-");
339    let inherited = access
340        .inherited_policy
341        .as_deref()
342        .filter(|_| policy == "inherited")
343        .map_or_else(String::new, |inherited| format!(" ({inherited})"));
344    let _ = writeln!(
345        out,
346        "{}  {} {policy}{inherited}   {} {}",
347        paint.paint(&access.slug, Palette::key()),
348        label("access:"),
349        label("all staff:"),
350        access.all_staff_role.as_deref().unwrap_or("-"),
351    );
352
353    let columns = [
354        Column::whole("ID", 24, Palette::key()),
355        Column::new("ROLE", 12, anstyle::Style::new()),
356        Column::new("KIND", 5, anstyle::Style::new()),
357        Column::new("VIA", 9, Palette::label()),
358        Column::whole("WHO", 30, Palette::untrusted()),
359    ];
360    let rows: Vec<Vec<String>> = access
361        .entries
362        .iter()
363        .map(|entry| {
364            vec![
365                entry.id.clone(),
366                entry.role.clone(),
367                entry.kind.clone(),
368                entry.via.clone(),
369                entry.who.clone(),
370            ]
371        })
372        .collect();
373    out.push_str(&render(&columns, &rows, ctx));
374    out.push_str(&cursor_tally(access.entries.len(), None, ctx));
375    out
376}
377
378/// Where an operation has got to, in one line.
379#[must_use]
380pub fn operation(
381    operation: &crate::api::wiki::WikiOperation,
382    status: &crate::api::wiki::OperationStatus,
383) -> String {
384    let mut line = format!(
385        "operation {} {}: {}",
386        operation.kind, operation.id, status.status
387    );
388    if let Some(percentage) = status.percentage.filter(|_| !status.is_done()) {
389        let _ = write!(line, " {percentage:.0}%");
390    }
391    if let Some(slug) = status.page_slug() {
392        let _ = write!(line, " — page {slug}");
393    }
394    if let Some(grid) = status.grid_id() {
395        let _ = write!(line, ", grid {grid}");
396    }
397    if let Some(details) = &status.details {
398        let _ = write!(line, " ({details})");
399    }
400    line.push('\n');
401    line
402}
403
404/// The pages under one.
405///
406/// The slug leads, because it is what `wiki get` takes; the id follows, because
407/// it is what the Wiki's own addresses use. There are no titles: the Wiki does
408/// not send them in a listing, and asking for each would cost a request a row.
409#[must_use]
410pub fn pages(list: &CursorPage<WikiPageRef>, ctx: &Context) -> String {
411    let columns = [
412        Column::whole("SLUG", 60, Palette::key()),
413        Column::whole("ID", 10, Palette::label()),
414    ];
415    let rows: Vec<Vec<String>> = list
416        .results
417        .iter()
418        .map(|page| vec![page.slug.clone(), page.id.to_string()])
419        .collect();
420
421    let mut out = render(&columns, &rows, ctx);
422    out.push_str(&cursor_tally(
423        list.results.len(),
424        list.next_cursor.as_deref(),
425        ctx,
426    ));
427    out
428}
429
430/// One page: where it is and what it is called, then its text.
431///
432/// The slug leads, because it is what `wiki get` takes back. The text is
433/// fenced and shortened exactly like a description: somebody else wrote it,
434/// and a runbook is as long as a description is short.
435#[must_use]
436pub fn page(page: &WikiPage, ctx: &Context) -> String {
437    let mut out = String::with_capacity(256 + page.content.as_ref().map_or(0, String::len));
438    let paint = ctx.painter();
439    let label = |text: &str| paint.paint(text, Palette::label());
440
441    let _ = writeln!(
442        out,
443        "{}  {}",
444        paint.paint(&page.slug, Palette::key()),
445        page.title
446    );
447    let _ = writeln!(
448        out,
449        "{} {}   {} {}   {} {}",
450        label("id:"),
451        page.id,
452        label("type:"),
453        page.page_type.as_deref().unwrap_or("-"),
454        label("modified:"),
455        page.modified_at.as_deref().unwrap_or("-"),
456    );
457
458    if let Some(content) = page.content.as_deref().filter(|text| !text.is_empty()) {
459        // A draw.io diagram or a pasted image carries its picture inline as
460        // base64: kilobytes nobody can read, in every read of the page. Outside
461        // `--full` the payload is folded to its size; `--full` stays
462        // byte-for-byte, so nothing of the text is ever lost for good.
463        let folded;
464        let content = if ctx.description_lines.is_some() {
465            folded = fold_data_uris(content);
466            folded.as_str()
467        } else {
468            content
469        };
470        let (body, withheld) = crate::render::untrusted::head(content, ctx.description_lines);
471        crate::render::text::quoted_block(
472            &mut out,
473            &format!("wiki:{}", page.slug),
474            crate::render::untrusted::Author::Wiki,
475            &body,
476            withheld,
477            ctx,
478        );
479    }
480
481    out
482}
483
484/// Payloads shorter than this are left alone: folding a tiny icon would cost
485/// more words than it saves.
486const FOLD_OVER: usize = 256;
487
488/// `data:<type>;base64,<payload>` with every long payload replaced by
489/// `…(N KB, --full shows it)`. Everything else is left exactly as written.
490fn fold_data_uris(text: &str) -> String {
491    const MARKER: &str = ";base64,";
492    let mut out = String::with_capacity(text.len());
493    let mut rest = text;
494    while let Some(at) = rest.find("data:") {
495        let after = &rest[at..];
496        let Some(marker) = after.find(MARKER).filter(|&end| {
497            after[5..end]
498                .chars()
499                .all(|c| c.is_ascii_alphanumeric() || "+-./".contains(c))
500        }) else {
501            out.push_str(&rest[..at + 5]);
502            rest = &rest[at + 5..];
503            continue;
504        };
505        let start = at + marker + MARKER.len();
506        let length = rest[start..]
507            .find(|c: char| !(c.is_ascii_alphanumeric() || "+/=".contains(c)))
508            .unwrap_or(rest.len() - start);
509        out.push_str(&rest[..start]);
510        if length > FOLD_OVER {
511            // Bytes the payload decodes to, which is what a person means by a
512            // picture's size, in tenths of a kilobyte, rounded.
513            let tenths = (length * 3 / 4 * 10 + 512) / 1024;
514            let _ = write!(
515                out,
516                "…({}.{} KB, --full shows it)",
517                tenths / 10,
518                tenths % 10
519            );
520        } else {
521            out.push_str(&rest[start..start + length]);
522        }
523        rest = &rest[start + length..];
524    }
525    out.push_str(rest);
526    out
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::render::{Audience, Format};
533
534    #[test]
535    fn a_long_inline_picture_is_folded_to_its_size() {
536        let payload = "A".repeat(4096);
537        let text = format!(
538            "before {{% drawio data=\"data:image/svg+xml;base64,{payload}\" width=\"600\" %}} after"
539        );
540        assert_eq!(
541            fold_data_uris(&text),
542            "before {% drawio data=\"data:image/svg+xml;base64,…(3.0 KB, --full shows it)\" width=\"600\" %} after"
543        );
544    }
545
546    #[test]
547    fn a_short_payload_and_plain_text_are_left_alone() {
548        let text = "icon ![x](data:image/png;base64,iVBORw0KGgo=) and the word data: here";
549        assert_eq!(fold_data_uris(text), text);
550    }
551
552    #[test]
553    fn full_shows_an_inline_picture_byte_for_byte() {
554        let content = format!("x data:image/png;base64,{}", "B".repeat(1000));
555        let mut shown = sample();
556        shown.content = Some(content.clone());
557        let mut full = ctx();
558        full.description_lines = None;
559        assert!(page(&shown, &full).contains(&content));
560        assert!(page(&shown, &ctx()).contains("…(0.7 KB, --full shows it)"));
561    }
562
563    fn ctx() -> Context {
564        Context {
565            format: Format::Text,
566            audience: Audience::Machine,
567            description_lines: Some(2),
568            extra_fields: Vec::new(),
569            width: 80,
570            images: false,
571            inline: crate::render::image::Inline::default(),
572        }
573    }
574
575    fn sample() -> WikiPage {
576        WikiPage {
577            id: 4521,
578            slug: "users/ilubenets/runbook".to_owned(),
579            title: "Deploy runbook".to_owned(),
580            page_type: Some("wysiwyg".to_owned()),
581            modified_at: Some("2026-09-01T10:15:00Z".to_owned()),
582            content: Some("# Deploy\n\n1. Tag the release.\n2. Watch the pipeline.".to_owned()),
583        }
584    }
585
586    /// A listing with more to come: every row, then a tally that does not
587    /// pretend to know the total.
588    #[test]
589    fn pages_view_is_stable() {
590        let list = CursorPage {
591            results: vec![
592                WikiPageRef {
593                    id: 4521,
594                    slug: "users/ilubenets/runbook".to_owned(),
595                },
596                WikiPageRef {
597                    id: 4522,
598                    slug: "users/ilubenets/runbook/rollback".to_owned(),
599                },
600            ],
601            next_cursor: Some("eyJpZCI6NDUyMn0=".to_owned()),
602        };
603        insta::assert_snapshot!(pages(&list, &ctx()));
604    }
605
606    /// Hits with another page to come; titles are data, the tally names the
607    /// next page because search is numbered.
608    #[test]
609    fn hits_view_is_stable() {
610        let found = WikiHits {
611            results: vec![
612                crate::api::wiki::WikiHit {
613                    slug: "users/ilubenets/runbook".to_owned(),
614                    title: "Deploy runbook".to_owned(),
615                    kind: "page".to_owned(),
616                    modified_at: Some("2026-09-01T10:15:00Z".to_owned()),
617                    url: None,
618                    snippet: Some("tag the release".to_owned()),
619                },
620                crate::api::wiki::WikiHit {
621                    slug: "users/ilubenets/runbook/.files/rollback.pdf".to_owned(),
622                    title: "rollback.pdf".to_owned(),
623                    kind: "file".to_owned(),
624                    modified_at: None,
625                    url: None,
626                    snippet: None,
627                },
628            ],
629            next_page: Some(2),
630        };
631        insta::assert_snapshot!(hits(&found, &ctx()));
632    }
633
634    /// A thread worth opening, a resolved comment, and a deleted one that
635    /// keeps its place but has nothing to fence.
636    #[test]
637    fn comments_view_is_stable() {
638        let comment = |id: u64, body: &str| WikiComment {
639            id,
640            author: Some("ilubenets".to_owned()),
641            created_at: Some("2026-09-02T08:00:00Z".to_owned()),
642            body: body.to_owned(),
643            resolved: false,
644            deleted: false,
645            quote: None,
646            thread_posts: Some(1),
647        };
648        let list = CursorPage {
649            results: vec![
650                WikiComment {
651                    thread_posts: Some(3),
652                    ..comment(7001, "Step 2 needs the canary first.")
653                },
654                WikiComment {
655                    resolved: true,
656                    ..comment(7002, "Typo in the title.")
657                },
658                WikiComment {
659                    deleted: true,
660                    ..comment(7003, "")
661                },
662            ],
663            next_cursor: None,
664        };
665        insta::assert_snapshot!(comments("users/ilubenets/runbook", &list, &ctx()));
666    }
667
668    /// Files with more to come; a missing type and date fall back to a dash.
669    #[test]
670    fn attachments_view_is_stable() {
671        let list = CursorPage {
672            results: vec![
673                WikiAttachment {
674                    id: 901,
675                    name: "rollback.pdf".to_owned(),
676                    size: "0.25".to_owned(),
677                    mimetype: Some("application/pdf".to_owned()),
678                    created_at: Some("2026-09-01T11:00:00Z".to_owned()),
679                    author: Some("ilubenets".to_owned()),
680                    download_url: None,
681                },
682                WikiAttachment {
683                    id: 902,
684                    name: "notes.txt".to_owned(),
685                    size: "-".to_owned(),
686                    mimetype: None,
687                    created_at: None,
688                    author: None,
689                    download_url: None,
690                },
691            ],
692            next_cursor: Some("eyJpZCI6OTAyfQ==".to_owned()),
693        };
694        insta::assert_snapshot!(attachments(&list, &ctx()));
695    }
696
697    fn sample_grid() -> WikiGrid {
698        use crate::api::wiki::{GridColumn, GridRow};
699        let column = |slug: &str, title: &str, kind: &str| GridColumn {
700            slug: slug.to_owned(),
701            title: title.to_owned(),
702            kind: kind.to_owned(),
703        };
704        WikiGrid {
705            id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
706            title: "Releases".to_owned(),
707            page: Some(WikiPageRef {
708                id: 4521,
709                slug: "users/ilubenets/runbook".to_owned(),
710            }),
711            revision: "12".to_owned(),
712            columns: vec![
713                column("version", "Version", "string"),
714                column("owner", "Owner", "staff"),
715                column("ticket", "Ticket", "ticket"),
716                column("status", "Status", "ticket_field"),
717                column("done", "Done", "checkbox"),
718            ],
719            rows: vec![
720                GridRow {
721                    id: "1".to_owned(),
722                    cells: vec![
723                        serde_json::json!("1.2.0"),
724                        serde_json::json!([{"username": "ilubenets", "display_name": "Ilya"}]),
725                        serde_json::json!({"key": "PROJ-1", "resolved": false}),
726                        serde_json::json!({"key": "inProgress", "display": "В работе"}),
727                        serde_json::json!(false),
728                    ],
729                },
730                GridRow {
731                    id: "2".to_owned(),
732                    cells: vec![
733                        serde_json::json!("1.3.0\tbeta\nsecond line"),
734                        serde_json::json!([]),
735                        serde_json::Value::Null,
736                        serde_json::Value::Null,
737                        serde_json::json!(true),
738                    ],
739                },
740            ],
741        }
742    }
743
744    /// Cells flattened to what a person reads, tabs and newlines escaped so
745    /// a row stays a line, and the whole table inside one fence.
746    #[test]
747    fn grid_view_is_stable() {
748        let full = Context {
749            description_lines: None,
750            ..ctx()
751        };
752        insta::assert_snapshot!(grid(&sample_grid(), &full));
753    }
754
755    /// Cut like a description: the tally counts the rows actually shown.
756    #[test]
757    fn a_long_grid_is_cut_and_says_so() {
758        let text = grid(&sample_grid(), &ctx());
759        assert!(text.contains("(+1 more lines: --full)"), "{text}");
760        assert!(text.trim_end().ends_with("shown 1 of 2"), "{text}");
761    }
762
763    #[test]
764    fn grids_view_is_stable() {
765        let list = CursorPage {
766            results: vec![WikiGridRef {
767                id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
768                title: "Releases".to_owned(),
769                created_at: Some("2026-08-01T09:00:00Z".to_owned()),
770            }],
771            next_cursor: None,
772        };
773        insta::assert_snapshot!(grids(&list, &ctx()));
774    }
775
776    #[test]
777    fn resources_view_is_stable() {
778        let list = CursorPage {
779            results: vec![
780                WikiResource {
781                    kind: "attachment".to_owned(),
782                    id: "901".to_owned(),
783                    name: "rollback.pdf".to_owned(),
784                    created_at: Some("2026-09-01T11:00:00Z".to_owned()),
785                },
786                WikiResource {
787                    kind: "grid".to_owned(),
788                    id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
789                    name: "Releases".to_owned(),
790                    created_at: None,
791                },
792            ],
793            next_cursor: Some("eyJpZCI6OTAyfQ==".to_owned()),
794        };
795        insta::assert_snapshot!(resources(&list, &ctx()));
796    }
797
798    /// A custom policy with a direct grant to a user and an inherited one to
799    /// a group; the list each came from is part of the answer.
800    #[test]
801    fn access_view_is_stable() {
802        use crate::api::wiki::{AccessEntry, WikiAccess};
803        let entry = |id: &str, role: &str, kind: &str, who: &str, via: &str| AccessEntry {
804            id: id.to_owned(),
805            role: role.to_owned(),
806            kind: kind.to_owned(),
807            who: who.to_owned(),
808            via: via.to_owned(),
809            inheritance: None,
810        };
811        let page_access = WikiAccess {
812            slug: "users/ilubenets/runbook".to_owned(),
813            policy: Some("custom".to_owned()),
814            inherited_policy: None,
815            all_staff_role: None,
816            entries: vec![
817                entry("a1", "author", "user", "ilubenets", "direct"),
818                entry("g7", "reader", "group", "Backend team", "inherited"),
819            ],
820        };
821        insta::assert_snapshot!(access(&page_access, &ctx()));
822    }
823
824    /// Running, then finished: the percentage while it means something, and
825    /// what the clone made once it is done.
826    #[test]
827    fn operation_view_is_stable() {
828        use crate::api::wiki::{OperationStatus, WikiOperation};
829        let grid = WikiOperation {
830            id: "op2".to_owned(),
831            kind: "clone_inline_grid".to_owned(),
832        };
833        let running = OperationStatus {
834            status: "in_progress".to_owned(),
835            percentage: Some(40.0),
836            details: None,
837            result: None,
838        };
839        let done = OperationStatus {
840            status: "success".to_owned(),
841            percentage: Some(100.0),
842            details: None,
843            result: Some(serde_json::json!({
844                "grid_id": "4c1d2e3f-0000-4000-8000-000000000002",
845                "page": {"id": 4700, "slug": "users/ilubenets/other"}
846            })),
847        };
848        insta::assert_snapshot!(operation(&grid, &running) + &operation(&grid, &done));
849    }
850
851    /// The compact view is the contract with every caller, so it is pinned.
852    #[test]
853    fn page_compact_view_is_stable() {
854        insta::assert_snapshot!(page(&sample(), &ctx()));
855    }
856
857    /// The terminal form too, escape codes and all.
858    #[test]
859    fn page_terminal_view_is_stable() {
860        let human = Context {
861            audience: Audience::Human,
862            ..ctx()
863        };
864        insta::assert_snapshot!(page(&sample(), &human));
865    }
866}