Skip to main content

flower_core/
page.rs

1//! The **page** projection: one level of the document at a time.
2//!
3//! [`tree`](crate::tree) answers "what does the whole document look like?" — every
4//! visible node, indented by depth. That is the right shape for a small config and
5//! the wrong one for a deep config, where the useful levels drift right until the
6//! keys no longer fit and every screen is mostly ancestors you already know about.
7//!
8//! A page answers a narrower question: "what is *in* this container?" It lists one
9//! container's children and nothing below them, so depth costs a navigation step
10//! instead of a column of indentation, and a document nested twelve deep renders
11//! exactly as wide as one nested twice. It is the model behind a settings menu —
12//! a stable list of categories, and a page you push into and pop back out of.
13//!
14//! ## Inline vs. drill
15//!
16//! Listing one level mechanically would be a poor settings menu: a two-key group
17//! would cost a whole page to show two lines, and you would spend the interaction
18//! budget on containers rather than values. Real settings menus don't do that;
19//! they inline the small groups and reserve a page for the substantial ones.
20//!
21//! So a container is **inlined** into its parent's page — a titled group, its
22//! members listed underneath — when its whole subtree fits an [`InlineBudget`]
23//! ([`inlines`]); otherwise it becomes a **drill** row that opens a page of its
24//! own. The test is deliberately structural rather than schema-driven: flower has
25//! to be useful on a document nobody has described. A [`Schema`](crate::Schema)
26//! can supersede it later — declared group titles, ordering, an "advanced"
27//! section — and feed the same renderer, because the shape it produces is the
28//! same.
29//!
30//! How much fits is the embedder's call, because it is a fact about the room
31//! rather than about the document: a frontmatter panel showing one small file
32//! wants the whole document on one page, and a deep CI config read in a narrow
33//! pane wants a page per level. The budget's per-subtree limits are a row count
34//! — the honest cost of inlining, since every descendant is a row — and a
35//! depth, the number of ranks of nesting a page is willing to draw. The default
36//! ([`InlineBudget::default`]) is the founding rule: at most [`INLINE_MAX`]
37//! members, one rank — a small, all-scalar group and nothing else.
38//!
39//! The decision is per **subtree**, made once at the top: a container either
40//! fits entirely or drills. Nothing inside an inlined subtree drills, so a page
41//! never nests navigation inside itself, and editing one field can only change
42//! how that field's own container renders — never a neighbour's.
43//!
44//! ## What the page can afford
45//!
46//! A per-subtree budget is blind to how many subtrees there are, and a list is
47//! where that blindness shows. Twenty-two entries of three scalars each pass it
48//! twenty-two times over and put eighty rows on one page: four screens of group
49//! rules, where the thing a list is *for* — reading one entry against the next —
50//! needed one. Every individual yes was right and the page is still wrong.
51//!
52//! So a sequence is asked a second question, once for all of it
53//! ([`InlineBudget::page_rows`]): do the rows its items would contribute fit the
54//! room a page has? When they don't, the list is a list — one titled, summarised
55//! row per entry, which is the rendering that makes entries comparable anyway.
56//! Only sequences are held to it. Their answer is already all-or-nothing
57//! ([`seq_inlines`]), where a mapping's children are decided one at a time and a
58//! running total would inline whichever key happened to be written first.
59//!
60//! ## Fitting the room
61//!
62//! Both limits above are constants, and a constant cannot be right about a room
63//! it has never seen. [`InlineBudget::fitting`] asks the document and the room
64//! instead: a document that fits entirely is *drawn* entirely, so a file nobody
65//! needs to navigate costs no navigation, and one that doesn't falls back to the
66//! founding rule with the page's limit set to the room. That is the general form
67//! of the observation that a file which is only one array belongs on one page —
68//! the shape of the document is not what settles it, the size of it against the
69//! room is.
70//!
71//! Inlining is a *presentation* default, never a cage: a group header keeps its
72//! own path, so it stays selectable, deletable, and openable as a page like any
73//! other container.
74//!
75//! ## Compression
76//!
77//! Inlining handles a container too *small* to deserve a page. The opposite
78//! shape needs handling too: a container whose single child is a map, which
79//! cannot inline (it is not all scalars) and so earns a page — with one row on
80//! it, naming the thing you just tapped.
81//!
82//! The rule that rejects a page for a group header rejects this one for the same
83//! reason: a container is worth a page when the page tells you something, and a
84//! page listing one drill row does not. So such a row **compresses**: `exports`
85//! holding only `journal` renders as one row reading `exports › journal`, and
86//! opening it lands on `journal`'s page. The chain is followed as far as it goes
87//! ([`PageItem::descend_to`]), through sequence indices as well as keys.
88//!
89//! It is one row, but it is not a new kind of node. Its
90//! [`path`](PageItem::path) is still the outermost container, so every op takes
91//! it unchanged and none of them needed a special case — deleting a row that
92//! reads `exports › journal` removes the whole chain, which is what it says it
93//! is, and leaves no empty `exports` behind. Only opening reads `descend_to`.
94//!
95//! Backing out retraces it: [`Model::page_back`](crate::Model::page_back) walks
96//! out past every level a row compressed past, so leaving costs the step that
97//! arriving cost. Popping one raw segment instead would land on the page the
98//! compression existed to skip — one row, naming the place you just left — and
99//! make the way out twice as long as the way in.
100//!
101//! The container the row named keeps every op regardless, because the row keeps
102//! its [`path`](PageItem::path): renaming, deleting or adding to `exports` are
103//! that row's ops on the page you land on. Compression makes a page cheaper to
104//! reach and its container no harder to operate.
105//!
106//! ## Demotion
107//!
108//! Inlining decides how much room a field gets; **demotion** decides how far up
109//! it sits. A document can carry fields nobody came here to type in — a hash the
110//! workspace recomputes on every write, a relation the sidebar owns, an identity
111//! nothing hand-edits — and listing them among the fields that *are* typed in
112//! makes the reader scan past them every time.
113//!
114//! Hiding them is the wrong answer: a field you can see in the file and not in
115//! the editor reads as data loss. So an embedder names those top-level keys
116//! ([`Model::set_demoted`](crate::Model::set_demoted)) and they render below the
117//! rest, marked [`PageItem::demoted`] — present, editable by whatever owns them,
118//! and out of the way. [`Page::partitioned`] is the fold.
119//!
120//! Demotion is a property of the whole subtree, not of the row: open a demoted
121//! container and its page is demoted too ([`Page::demoted`]). A section that
122//! stopped being "advanced" one level in would be a section only at the root.
123
124use std::collections::{HashMap, HashSet};
125
126use fig::Value;
127pub use fig_schema::Seg;
128
129use crate::tree::{VKind, key_to_string, preview, value_at};
130
131/// The row limit of the **default** [`InlineBudget`].
132///
133/// Six is the point where a group stops reading as a handful of related fields
134/// and starts reading as a list — and where inlining two of them in a row would
135/// fill a short terminal with somebody else's fields. It is a presentation
136/// constant, not a correctness one: raising it inlines more, lowering it drills
137/// more, and nothing else changes.
138pub const INLINE_MAX: usize = 6;
139
140/// The row limit of the **default** [`InlineBudget`]'s *page*.
141///
142/// Twenty is about a short terminal's body: the point past which a list has
143/// stopped being something you read one entry of against the next, and become
144/// something you scroll. It bounds what [`INLINE_MAX`] cannot — a list of
145/// entries each small enough to inline and numerous enough that inlining all of
146/// them buries the page.
147pub const PAGE_INLINE_MAX: usize = 20;
148
149/// The deepest document [`InlineBudget::fitting`] will pour onto one page.
150///
151/// Rows are not the only thing a page spends: every rank of nesting is two more
152/// columns of inset on every row below it. A document that fits vertically and
153/// runs eight ranks deep fits by the row count and not by the eye, so past this
154/// it drills however short it is.
155pub const FIT_MAX_DEPTH: usize = 3;
156
157/// How much of a container's subtree may be inlined into its parent's page
158/// rather than drilled into — the knob that slides the page projection between
159/// its two ancestors.
160///
161/// At the default, a page is a settings menu: small all-scalar groups inline,
162/// everything substantial earns a page. Raised far enough, the root page simply
163/// *is* the whole document — the settings-list rendering, absorbed — and a
164/// small document never asks for a navigation step at all. The embedder picks,
165/// because the right answer is about the room the pages are drawn in, not
166/// about the document.
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
168pub struct InlineBudget {
169    /// The most rows an inlined subtree may contribute to the page. Every
170    /// descendant is a row — nested containers add their headers too — so this
171    /// is the honest cost of saying yes.
172    pub rows: usize,
173    /// The most ranks of nesting an inlined subtree may reach: 1 admits only
174    /// all-scalar groups, 2 lets those groups hold one more rank of groups, and
175    /// so on. Rendered as [`PageItem::inset`], so this bounds the indentation a
176    /// page can ask a frontend to draw.
177    pub depth: usize,
178    /// The most rows one page will spend on the items of a sequence it inlines
179    /// ([`seq_inlines`]) — the limit [`rows`](Self::rows) cannot express,
180    /// because that one is asked once per item and this one once per page.
181    ///
182    /// Twenty-two entries of three scalars each pass a per-item budget
183    /// individually and put eighty rows on one page between them. Every
184    /// individual yes was right and the page is still wrong, so the page gets a
185    /// say of its own.
186    pub page_rows: usize,
187}
188
189impl InlineBudget {
190    /// A budget from the two per-subtree limits, with a page limit at least as
191    /// generous as [`PAGE_INLINE_MAX`].
192    ///
193    /// Raising `rows` past it raises the page's limit with it: a caller asking
194    /// for a hundred rows of subtree is asking for a page that can hold them,
195    /// and a page cap left at the default would refuse what the caller just
196    /// paid for.
197    pub fn new(rows: usize, depth: usize) -> Self {
198        Self {
199            rows,
200            depth,
201            page_rows: rows.max(PAGE_INLINE_MAX),
202        }
203    }
204
205    /// The budget that shows as much of `root` as `room` rows allow.
206    ///
207    /// The founding rule is a constant, and a constant cannot be right about a
208    /// room it has never seen: six rows and one rank is a wise default over a
209    /// deep CI config in a narrow pane, and a needless navigation step over a
210    /// document that would have fit on screen whole. This asks the document and
211    /// the room instead.
212    ///
213    /// Two answers. If the whole document fits — few enough rows, and shallow
214    /// enough ([`FIT_MAX_DEPTH`]) that the insets stay readable — the budget is
215    /// the document's own size and the root page simply *is* the document: no
216    /// navigation at all for a file that never needed any, which is the general
217    /// form of "a file that is only one array belongs on one page". Otherwise it
218    /// is the founding rule with the page limit set to the room, so a taller
219    /// terminal inlines a longer list and a short one does not.
220    ///
221    /// Measured over the whole document, hidden keys included: they are the
222    /// embedder's few reserved names, and counting them costs at most a row of
223    /// slack in a heuristic that is choosing between two roundings anyway.
224    pub fn fitting(root: &Value, room: usize) -> Self {
225        let (rows, depth) = subtree_shape(root);
226        if rows > 0 && rows <= room && depth <= FIT_MAX_DEPTH {
227            return Self {
228                rows,
229                depth,
230                page_rows: rows,
231            };
232        }
233        Self {
234            page_rows: room.max(INLINE_MAX),
235            ..Self::default()
236        }
237    }
238}
239
240impl Default for InlineBudget {
241    /// The founding rule: at most [`INLINE_MAX`] members, all of them scalars,
242    /// and at most [`PAGE_INLINE_MAX`] rows of them on any one page.
243    fn default() -> Self {
244        Self::new(INLINE_MAX, 1)
245    }
246}
247
248/// What a [`PageItem`] does when you activate it.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub enum ItemKind {
251    /// A leaf: editable in place.
252    Scalar,
253    /// A container substantial enough to earn its own page. `count` is how many
254    /// children it holds — what a "12 fields ›" affordance shows.
255    Drill { count: usize },
256    /// The title of a container inlined into *this* page. The items that follow it
257    /// at [`PageItem::inset`] 1 are its members.
258    ///
259    /// Selectable, and openable as a page in its own right: the inline rendering
260    /// is a default, not a restriction.
261    GroupHeader { count: usize },
262}
263
264/// One line of a page.
265#[derive(Clone, Debug)]
266pub struct PageItem {
267    /// The fig path to this node from the document root — the same currency
268    /// [`tree`](crate::tree) deals in, so an edit op takes it unchanged.
269    pub path: Vec<Seg>,
270    /// The mapping key, or `[i]` for a sequence item.
271    pub label: String,
272    pub vkind: VKind,
273    /// A one-line rendering of the value (the scalar text, or `{n}` / `[n]`).
274    pub preview: String,
275    pub kind: ItemKind,
276    /// 0 for a direct child of the page's focus; 1 for a member of a group
277    /// inlined into it, and one more for each further rank the [`InlineBudget`]
278    /// admitted. Bounded by [`InlineBudget::depth`], so the default budget never
279    /// goes past 1.
280    pub inset: usize,
281    /// A readable stand-in for a sequence item's index — the value of whichever
282    /// of its fields best names it ([`title_keys`]). `None` for a mapping entry,
283    /// whose key already names it, and for an item nothing distinguishes.
284    ///
285    /// It never replaces [`label`](Self::label): the index is what the path is
286    /// addressed by and what a reorder moves, so a frontend shows both.
287    pub title: Option<String>,
288    /// Where opening this row lands, when the pages between here and there would
289    /// each list nothing but the next step down.
290    ///
291    /// Equal to [`path`](Self::path) for almost every row. It differs for a
292    /// **compressed** drill — `exports › journal`, one row standing for a chain
293    /// of containers that hold only each other — where it is the deepest of
294    /// them, the first one whose page has something to say.
295    ///
296    /// [`path`](Self::path) stays the outermost node, so every op still takes it
297    /// unchanged and none of them needed a special case: deleting the row
298    /// removes the whole chain (which is what deleting something called
299    /// `exports › journal` should do, and leaves no empty husk behind), and
300    /// renaming it renames `exports`. Only *opening* looks here.
301    pub descend_to: Vec<Seg>,
302    /// Whether this item belongs *below* the fields a reader came here to edit
303    /// — a page's own "advanced" section, in the sense a settings menu means it.
304    ///
305    /// Set by the embedder's demoted-key set, and root-scoped exactly as
306    /// [`build_page`]'s hiding is: it is a property of the whole subtree under a
307    /// top-level key, so every item on a demoted container's page is demoted too
308    /// and the section cannot come apart when you drill into it.
309    ///
310    /// A demotion, not a hiding and not a lock: the item renders, carries its
311    /// path, and takes every op the others take. It says only that a reader
312    /// scanning for the field they meant to change should not have to read past
313    /// this one to find it.
314    pub demoted: bool,
315    /// A container's entire contents in flow form (`{branches: [main]}`), when
316    /// they are short enough to be worth showing instead of counting.
317    ///
318    /// `1 field ›` is strictly less than the document says: the field is right
319    /// there and it fits. A count is what you fall back to when the contents
320    /// don't ([`SUMMARY_BUDGET`]), not the default way to describe a small
321    /// container. `None` for a scalar, whose value is already its own row.
322    pub summary: Option<String>,
323}
324
325impl PageItem {
326    /// Whether activating this item opens a page (rather than editing a value).
327    ///
328    /// A group header does **not**, though it names a container: its members are
329    /// already on this page, so the page it would open shows exactly what you can
330    /// already see — the same two rows twice, once on each side of a split. A
331    /// container is worth a page when the page tells you something; this one
332    /// cannot. Its members are reached by moving onto them, and every op that
333    /// takes the group itself takes a path, which the header still carries.
334    pub fn is_drill(&self) -> bool {
335        matches!(self.kind, ItemKind::Drill { .. })
336    }
337
338    /// Whether this item names a container at all — a drill row, or the header of
339    /// a group inlined into this page.
340    pub fn is_container(&self) -> bool {
341        matches!(
342            self.kind,
343            ItemKind::Drill { .. } | ItemKind::GroupHeader { .. }
344        )
345    }
346
347    pub fn is_scalar(&self) -> bool {
348        matches!(self.kind, ItemKind::Scalar)
349    }
350
351    /// Whether this item's *label* can be changed — true for a mapping entry,
352    /// false for a sequence item.
353    ///
354    /// A sequence item's label is its index: it is the position, not a name, so
355    /// there is nothing to rename and the only thing that moves it is a reorder.
356    /// The inference is one line, which is exactly why it belongs here — every
357    /// frontend that redid it would be one edit away from disagreeing with the
358    /// op that actually refuses.
359    pub fn can_rename(&self) -> bool {
360        matches!(self.path.last(), Some(Seg::Key(_)))
361    }
362
363    /// Whether this row stands for a chain of containers rather than for one
364    /// ([`descend_to`](Self::descend_to)).
365    pub fn is_compressed(&self) -> bool {
366        self.descend_to.len() > self.path.len()
367    }
368
369    /// The names this row shows, outermost first — `["exports", "journal"]` for a
370    /// compressed drill, and just the label for every other row. A frontend joins
371    /// them with whatever separator its breadcrumb uses.
372    pub fn chain_labels(&self) -> Vec<String> {
373        std::iter::once(self.label.clone())
374            .chain(
375                self.descend_to[self.path.len().min(self.descend_to.len())..]
376                    .iter()
377                    .map(seg_label),
378            )
379            .collect()
380    }
381}
382
383/// One container's children, ready to render.
384#[derive(Clone, Debug, Default)]
385pub struct Page {
386    /// The container being listed. Empty is the document root.
387    pub focus: Vec<Seg>,
388    pub items: Vec<PageItem>,
389    /// What this page's own container is called, when it is a sequence item and
390    /// its index is not worth reading — the same title its row carried on the
391    /// page you opened it from, so the breadcrumb agrees with what you clicked.
392    pub title: Option<String>,
393    /// Whether this whole page sits under a demoted top-level key.
394    ///
395    /// The page you reach by opening a demoted row. A frontend that folds its
396    /// demoted items behind an "advanced" disclosure reads this to keep the
397    /// framing once you are inside — the section a page came out of is still
398    /// true of the page.
399    pub demoted: bool,
400}
401
402impl Page {
403    pub fn is_empty(&self) -> bool {
404        self.items.is_empty()
405    }
406
407    /// Where `path` sits in this page, if it is on it.
408    ///
409    /// A compressed row answers for its whole chain: both the node it *is*
410    /// (`exports`) and the node it *opens* (`exports.journal`) find it, because
411    /// every caller is asking the same question — which row here corresponds to
412    /// that node — and for a chain the answer is the one row standing for all of
413    /// it. Identical to matching on the path alone for any row that is not
414    /// compressed, where the two are the same.
415    pub fn position_of(&self, path: &[Seg]) -> Option<usize> {
416        self.items
417            .iter()
418            .position(|i| i.path == path || i.descend_to == path)
419    }
420
421    /// Whether any item on this page opens a page of its own.
422    ///
423    /// A page with none is a leaf of the navigation, and — at the root — a
424    /// document with no depth to navigate at all, which is how a frontend knows
425    /// to spend the whole width on one pane instead of drawing an empty second
426    /// one. See [`Model::pages_would_degenerate`](crate::Model::pages_would_degenerate).
427    pub fn has_drills(&self) -> bool {
428        self.items.iter().any(PageItem::is_drill)
429    }
430
431    /// Whether this page offers a choice — two rows or more.
432    ///
433    /// What a pane full of it would be *for*. A frontend drawing a sidebar reads
434    /// it to find out whether there is anything to select between: one row is a
435    /// label, not a menu, and half a screen is a lot to spend on a label.
436    pub fn has_choice(&self) -> bool {
437        self.items.len() >= 2
438    }
439
440    /// The page's items in two stable runs: the ones a reader came to edit, then
441    /// the demoted ones.
442    ///
443    /// [`items`](Self::items) stays in document order, because that order is the
444    /// document's and flower does not get to reshuffle it. This is the one
445    /// rearrangement a settings menu does want — the "advanced" fold — offered
446    /// here rather than left to each frontend so they all fold at the same seam.
447    ///
448    /// The partition is stable, and demotion is root-scoped, so a group header
449    /// and the members inlined under it always land in the same run, adjacent and
450    /// in order: the fold can never cut a group in half.
451    pub fn partitioned(&self) -> (Vec<&PageItem>, Vec<&PageItem>) {
452        self.items.iter().partition(|i| !i.demoted)
453    }
454
455    /// The page's title as a breadcrumb — `server › limits`, or `root_label` for
456    /// the document root.
457    pub fn breadcrumb(&self, root_label: &str) -> String {
458        if self.focus.is_empty() {
459            return root_label.to_string();
460        }
461        let mut parts: Vec<String> = self.focus.iter().map(seg_label).collect();
462        if let (Some(title), Some(last)) = (&self.title, parts.last_mut()) {
463            *last = title.clone();
464        }
465        parts.join(" › ")
466    }
467}
468
469/// How a path segment reads in a breadcrumb or a label.
470pub fn seg_label(seg: &Seg) -> String {
471    match seg {
472        Seg::Key(k) => k.clone(),
473        Seg::Index(i) => format!("[{i}]"),
474    }
475}
476
477/// Whether `v` is a container at all — the test for whether a path can be focused.
478pub fn is_container(v: &Value) -> bool {
479    matches!(v, Value::Map(_) | Value::Seq(_))
480}
481
482/// How many children `v` holds (0 for a scalar).
483fn child_count(v: &Value) -> usize {
484    match v {
485        Value::Map(entries) => entries.len(),
486        Value::Seq(items) => items.len(),
487        _ => 0,
488    }
489}
490
491/// Whether `v` is inlined into its parent's page rather than given one of its
492/// own: a non-empty container whose whole subtree fits `budget` — few enough
493/// rows, and nested no deeper than the budget's rank limit.
494///
495/// An empty container is excluded deliberately. It has nothing to inline, and a
496/// titled group with no members under it reads as a rendering bug; as a drill row
497/// it stays visible, countable, and somewhere to add the first key.
498pub fn inlines(v: &Value, budget: InlineBudget) -> bool {
499    let (rows, depth) = subtree_shape(v);
500    rows > 0 && rows <= budget.rows && depth <= budget.depth
501}
502
503/// Whether a sequence's items are inlined into its page — all of them, or none.
504///
505/// Two tests, and a list has to pass both. Every item must fit `budget` on its
506/// own (the founding rule, applied item by item), *and* the rows they would
507/// contribute between them must fit [`InlineBudget::page_rows`].
508///
509/// The second is the one a list needs and the per-item test cannot give it,
510/// because a per-item test is blind to how many items there are. Twenty-two
511/// entries of three scalars each say yes twenty-two times and put eighty rows on
512/// one page: four screens of group rules, where the thing a list is *for* —
513/// reading one entry against the next — needed one. Drilled instead, the same
514/// twenty-two are twenty-two rows, each titled and summarised, and the
515/// comparison is back on screen.
516///
517/// A mapping is under no such rule. Its children have distinct names and are
518/// decided one at a time, so a running total would inline whichever happened to
519/// be written first and drill the rest — a page whose shape depends on key
520/// order, which is not a fact about the document. A sequence can be held to a
521/// total precisely because its answer is already all-or-nothing.
522fn seq_inlines(items: &[Value], budget: InlineBudget) -> bool {
523    let mut rows = 0usize;
524    for item in items {
525        if is_container(item) {
526            if !inlines(item, budget) {
527                return false;
528            }
529            // The header, then everything under it.
530            rows += 1 + subtree_shape(item).0;
531        } else {
532            // A scalar item is one row whatever is decided here — it has no
533            // subtree to inline — but it is still a row this page has to draw.
534            rows += 1;
535        }
536    }
537    rows <= budget.page_rows
538}
539
540/// The rendered cost of inlining `v`: how many rows its subtree would put on
541/// the page (every descendant is one — nested containers count their headers
542/// too), and how many ranks of inset the deepest of them would wear.
543/// `(0, 0)` for a scalar; an empty container is `(0, 1)`, which no budget
544/// accepts because there are no rows in it to want.
545fn subtree_shape(v: &Value) -> (usize, usize) {
546    let children: Box<dyn Iterator<Item = &Value>> = match v {
547        Value::Map(entries) => Box::new(entries.iter().map(|(_, c)| c)),
548        Value::Seq(items) => Box::new(items.iter()),
549        _ => return (0, 0),
550    };
551    let (mut rows, mut depth) = (0, 0);
552    for child in children {
553        let (r, d) = subtree_shape(child);
554        rows += 1 + r;
555        depth = depth.max(d);
556    }
557    (rows, depth + 1)
558}
559
560/// Keys that conventionally name the thing they sit in, best first.
561///
562/// A small list on purpose. It is a tie-breaker over the structural evidence
563/// below, not the mechanism: config files that call it something else are the
564/// common case, and a list long enough to cover them would start guessing wrong.
565const NAME_KEYS: [&str; 5] = ["name", "title", "id", "label", "key"];
566
567/// Rank the keys of a sequence's items by how well each one *names* an item,
568/// best first.
569///
570/// A sequence of mappings is the one place a config has no names to show: the
571/// items are addressed by index, and `[0]`, `[1]`, `[2]` tell you nothing about
572/// which step, service, or rule you are looking at. The information is there —
573/// it is just in a field rather than in a key — so this works out which field.
574///
575/// Three signals, in one score:
576///
577/// - **coverage** — how many items have this key at all, with a scalar value.
578/// - **distinctness** — how many of those values differ. A key that reads the
579///   same on every item cannot tell them apart, however faithfully it is filled
580///   in, so this is weighted hardest.
581/// - **convention** — whether it is one of [`NAME_KEYS`].
582///
583/// A *ranking* rather than a single answer, because items in the same sequence
584/// need not have the same keys: a GitHub Actions step is named by `uses` or by
585/// `run` depending on which kind of step it is, and each item takes the best
586/// key it actually has ([`title_of`]).
587pub fn title_keys(items: &[Value]) -> Vec<String> {
588    let mut order: Vec<String> = Vec::new();
589    let mut stats: HashMap<String, (usize, HashSet<String>)> = HashMap::new();
590    let mut mappings = 0usize;
591
592    for item in items {
593        let Value::Map(entries) = item else { continue };
594        mappings += 1;
595        for (k, v) in entries {
596            if is_container(v) {
597                continue;
598            }
599            let key = key_to_string(k);
600            let seen = stats.entry(key.clone()).or_insert_with(|| {
601                order.push(key);
602                (0, HashSet::new())
603            });
604            seen.0 += 1;
605            seen.1.insert(preview(v));
606        }
607    }
608    if mappings == 0 {
609        return Vec::new();
610    }
611
612    let mut ranked: Vec<(f64, usize, usize, &String)> = order
613        .iter()
614        .enumerate()
615        .map(|(doc_order, key)| {
616            let (present, values) = &stats[key];
617            let coverage = *present as f64 / mappings as f64;
618            let distinctness = values.len() as f64 / *present as f64;
619            let convention = NAME_KEYS.iter().position(|n| n.eq_ignore_ascii_case(key));
620            let score =
621                coverage + 1.5 * distinctness + if convention.is_some() { 2.0 } else { 0.0 };
622            (score, convention.unwrap_or(NAME_KEYS.len()), doc_order, key)
623        })
624        .collect();
625    // Best score first; ties settled by convention, then by the order the
626    // document itself puts the keys in — both stable, so a page does not
627    // reshuffle its titles when an unrelated field is edited.
628    ranked.sort_by(|a, b| {
629        b.0.total_cmp(&a.0)
630            .then_with(|| a.1.cmp(&b.1))
631            .then_with(|| a.2.cmp(&b.2))
632    });
633    ranked.into_iter().map(|(_, _, _, k)| k.clone()).collect()
634}
635
636/// The title `item` takes from a ranking: the value of the best-ranked key it
637/// actually has. `None` for a non-mapping, or one with none of the keys.
638pub fn title_of(ranking: &[String], item: &Value) -> Option<String> {
639    title_entry_of(ranking, item).map(|(_, title)| title)
640}
641
642/// [`title_of`], and the key the title came out of.
643///
644/// The key matters to whoever is about to describe the same mapping a second
645/// time on the same row: a summary that repeats the field the title is already
646/// showing spends the row's width saying it twice ([`flow_without`]).
647pub fn title_entry_of<'r>(ranking: &'r [String], item: &Value) -> Option<(&'r str, String)> {
648    let Value::Map(entries) = item else {
649        return None;
650    };
651    ranking.iter().find_map(|want| {
652        entries.iter().find_map(|(k, v)| {
653            (!is_container(v) && key_to_string(k) == *want).then(|| (want.as_str(), preview(v)))
654        })
655    })
656}
657
658/// How long a container's flow-form summary may get before a count is the more
659/// useful thing to show.
660///
661/// Generous, because the renderer applies the real limit — whatever room the row
662/// actually has — and falls back to the count on its own. This only stops the
663/// projection building a 4KB string for a container nobody could render anyway.
664pub const SUMMARY_BUDGET: usize = 72;
665
666/// A container's whole contents on one line, in flow form, or `None` if they run
667/// past `budget`.
668///
669/// Flow form because that is how the formats themselves write a small container
670/// — `{branches: [main]}` is valid YAML, JSON, and (near enough) TOML — so it
671/// reads as the document rather than as a rendering of it.
672pub fn flow(v: &Value, budget: usize) -> Option<String> {
673    let rendered = match v {
674        Value::Map(entries) => {
675            let parts = entries
676                .iter()
677                .map(|(k, val)| Some(format!("{}: {}", key_to_string(k), flow(val, budget)?)))
678                .collect::<Option<Vec<_>>>()?;
679            format!("{{{}}}", parts.join(", "))
680        }
681        Value::Seq(items) => {
682            let parts = items
683                .iter()
684                .map(|i| flow(i, budget))
685                .collect::<Option<Vec<_>>>()?;
686            format!("[{}]", parts.join(", "))
687        }
688        scalar => preview(scalar),
689    };
690    (rendered.chars().count() <= budget).then_some(rendered)
691}
692
693/// [`flow`], with one top-level key left out — the one a row is already showing
694/// as its title.
695///
696/// `[0] · diaryx  {name: diaryx, public: false, lang: rust+swift}` says `diaryx`
697/// twice in a row that has room for neither, and the copy it drops is the one
698/// the eye already read. An elision, like every summary: the field is on the
699/// page the row opens, and the row's count still counts it.
700///
701/// `None` when nothing is left — a mapping whose only field is its own name has
702/// no contents to show beyond the title, and the count says the rest.
703fn flow_without(v: &Value, budget: usize, omit: &str) -> Option<String> {
704    let Value::Map(entries) = v else {
705        return flow(v, budget);
706    };
707    let parts = entries
708        .iter()
709        .filter(|(k, _)| key_to_string(k) != omit)
710        .map(|(k, val)| Some(format!("{}: {}", key_to_string(k), flow(val, budget)?)))
711        .collect::<Option<Vec<_>>>()?;
712    if parts.is_empty() {
713        return None;
714    }
715    let rendered = format!("{{{}}}", parts.join(", "));
716    (rendered.chars().count() <= budget).then_some(rendered)
717}
718
719/// Build the page listing the container at `focus`.
720///
721/// Two root-scoped key sets shape the result, and they are root-scoped in the
722/// same sense but not in the same way:
723///
724/// - `hidden_top_level` is the hiding [`tree::build_rows`] honors (an embedder's
725///   managed keys), applied only when `focus` *is* the root — a hidden key
726///   produces no item, and a nested key that happens to share a hidden name is
727///   untouched.
728/// - `demoted_top_level` marks a key's whole **subtree**
729///   ([`PageItem::demoted`]), so it applies at every focus: the items of a
730///   demoted container's page are demoted, and so is the page
731///   ([`Page::demoted`]). That is what keeps an "advanced" section from coming
732///   apart the moment you open something inside it.
733///
734/// The asymmetry is deliberate. Hiding answers "does this row exist here?",
735/// which only the root can ask, since that is the level the embedder reserves
736/// keys at. Demotion answers "how prominent is this?", which stays true however
737/// deep you go.
738///
739/// A `focus` that doesn't resolve, or that names a scalar, yields an empty page.
740/// The projection stays total so a frontend never has to guard it; the model
741/// keeps `focus` on a real container anyway
742/// ([`Model::reanchor_focus`](crate::Model)).
743pub fn build_page(
744    root: &Value,
745    focus: &[Seg],
746    hidden_top_level: &HashSet<String>,
747    demoted_top_level: &HashSet<String>,
748    budget: InlineBudget,
749) -> Page {
750    let mut page = Page {
751        focus: focus.to_vec(),
752        items: Vec::new(),
753        title: page_title(root, focus),
754        demoted: under_demoted_root(focus, demoted_top_level),
755    };
756    let Some(node) = value_at(root, focus) else {
757        return page;
758    };
759    let at_root = focus.is_empty();
760
761    // A sequence's items render alike, whatever their individual sizes.
762    //
763    // Applying the inline test per item would expand whichever entries happen to
764    // be small and collapse the rest — a list where some rows are three lines and
765    // others are one, which reads as a rendering fault rather than as a list. It
766    // also destroys the one comparison a list is for: entry against entry. So a
767    // sequence inlines every mapping item or none ([`seq_inlines`]), and "none"
768    // is the answer as soon as one item is too big or too nested to inline — or
769    // as soon as there are too many of them to be worth a page between them.
770    //
771    // A mapping's children are under no such rule: they have distinct names, so
772    // a mix of inlined groups and drill rows reads as what it is.
773    let (uniform, ranking) = match node {
774        Value::Seq(items) => (Some(seq_inlines(items, budget)), title_keys(items)),
775        _ => (None, Vec::new()),
776    };
777
778    for (label, path, child) in children_of(node, focus) {
779        if at_root && hidden_top_level.contains(&label) {
780            continue;
781        }
782        // Every item on this page shares the page's root key when the focus is
783        // not the root, so off the root this is just `page.demoted` — one test
784        // that reads the same at both levels rather than two that agree by
785        // accident.
786        let demoted = under_demoted_root(&path, demoted_top_level);
787        let title = title_of(&ranking, child);
788        if !is_container(child) {
789            page.items.push(item(
790                label,
791                path,
792                child,
793                ItemKind::Scalar,
794                0,
795                title,
796                demoted,
797            ));
798        } else if uniform.unwrap_or(true) && inlines(child, budget) {
799            push_inline(&mut page.items, label, path, child, 0, title, demoted);
800        } else {
801            // A drill row stands for everything between here and the first page
802            // that has something to say: `exports` holding only `journal` is one
803            // row reading `exports › journal`, not two taps through a page whose
804            // whole content is a name you just tapped. The row is described by
805            // what it lands on — its count, its summary, its kind — while its
806            // path stays the outermost node, so every op still takes it
807            // unchanged.
808            let (descend_to, deep) = compress(&path, child, budget);
809            let count = child_count(deep);
810            let mut row = item(
811                label,
812                path,
813                deep,
814                ItemKind::Drill { count },
815                0,
816                title,
817                demoted,
818            );
819            // A titled row would otherwise describe itself twice — the title
820            // and the summary's first field are the same field — in a row that
821            // has room for neither.
822            //
823            // `child`, not `deep`, and the two are the same whenever this fires:
824            // compression needs a container whose *only* child is a container,
825            // and a title needs a scalar field, so a row can have one or the
826            // other and never both (`compression_and_a_title_cannot_meet`).
827            if let Some((key, _)) = title_entry_of(&ranking, child) {
828                row.summary = flow_without(child, SUMMARY_BUDGET, key);
829            }
830            row.descend_to = descend_to;
831            page.items.push(row);
832        }
833    }
834    page
835}
836
837/// Emit an inlined container: its header, then its whole subtree, each rank one
838/// inset deeper.
839///
840/// No budget here, deliberately: [`inlines`] measured the entire subtree before
841/// saying yes, so by the time this runs every node under `v` has already been
842/// paid for and nothing inside it can drill. The one thing computed per level is
843/// a sequence's title ranking, so a nested list names its items the way it would
844/// on a page of its own.
845fn push_inline(
846    items: &mut Vec<PageItem>,
847    label: String,
848    path: Vec<Seg>,
849    v: &Value,
850    inset: usize,
851    title: Option<String>,
852    demoted: bool,
853) {
854    let count = child_count(v);
855    items.push(item(
856        label,
857        path.clone(),
858        v,
859        ItemKind::GroupHeader { count },
860        inset,
861        title,
862        demoted,
863    ));
864    let ranking = match v {
865        Value::Seq(members) => title_keys(members),
866        _ => Vec::new(),
867    };
868    for (sub_label, sub_path, sub) in children_of(v, &path) {
869        let sub_title = title_of(&ranking, sub);
870        if is_container(sub) {
871            push_inline(
872                items,
873                sub_label,
874                sub_path,
875                sub,
876                inset + 1,
877                sub_title,
878                demoted,
879            );
880        } else {
881            items.push(item(
882                sub_label,
883                sub_path,
884                sub,
885                ItemKind::Scalar,
886                inset + 1,
887                sub_title,
888                demoted,
889            ));
890        }
891    }
892}
893
894/// The one child `v` holds, when `v` holds exactly one and that child would be a
895/// drill row on `v`'s own page.
896///
897/// The test for "opening this would show me a page with one row on it". A
898/// container with one child that *inlines* fails it: that page lists a group
899/// header and its members, which is several rows and a real answer to what is
900/// in there. So does a container with one scalar child, for the same reason.
901///
902/// A sequence is included. Its lone item is a drill by the uniformity rule
903/// (nothing to be uniform with, and it does not inline), and `audiences › [0]`
904/// is exactly as uninformative a page as the mapping case.
905fn lone_drill_child(v: &Value, budget: InlineBudget) -> Option<(Seg, &Value)> {
906    let (seg, child) = match v {
907        Value::Map(entries) if entries.len() == 1 => {
908            let (k, c) = entries.iter().next()?;
909            (Seg::Key(key_to_string(k)), c)
910        }
911        Value::Seq(items) if items.len() == 1 => (Seg::Index(0), items.first()?),
912        _ => return None,
913    };
914    (is_container(child) && !inlines(child, budget)).then_some((seg, child))
915}
916
917/// Follow [`lone_drill_child`] as far as it goes, from the container at `base`.
918///
919/// Returns where opening `v` should land and what is actually there. Terminates
920/// because every step is strictly deeper into a finite document.
921fn compress<'v>(base: &[Seg], v: &'v Value, budget: InlineBudget) -> (Vec<Seg>, &'v Value) {
922    let mut descend_to = base.to_vec();
923    let mut deep = v;
924    while let Some((seg, next)) = lone_drill_child(deep, budget) {
925        descend_to.push(seg);
926        deep = next;
927    }
928    (descend_to, deep)
929}
930
931/// Whether the page listing `focus` is one a row compressed past: it holds
932/// exactly one item, and that item opens a page of its own.
933///
934/// The same condition [`compress`] walks, asked from the other end. A frontend
935/// that skipped this level going in should not be handed it coming out — see
936/// [`Model::parent_page`](crate::Model::parent_page).
937pub fn is_compressed_past(
938    root: &Value,
939    focus: &[Seg],
940    hidden: &HashSet<String>,
941    budget: InlineBudget,
942) -> bool {
943    let page = build_page(root, focus, hidden, &HashSet::new(), budget);
944    page.items.len() == 1 && page.items[0].is_drill()
945}
946
947/// Whether `path` descends from a demoted top-level key.
948///
949/// The same root-scoped shape as
950/// [`Model::is_derived`](crate::Model::is_derived): only the first segment is
951/// consulted, and only when it is a key. A sequence at the root has no name to
952/// demote by, and a nested `id` under some other key is a user's own field that
953/// happens to share a managed key's spelling — neither is what the embedder
954/// named.
955fn under_demoted_root(path: &[Seg], demoted_top_level: &HashSet<String>) -> bool {
956    matches!(path.first(), Some(Seg::Key(k)) if demoted_top_level.contains(k))
957}
958
959/// The title of the container `focus` names, when it is a sequence item — the
960/// same one its row carried on the page it was opened from.
961fn page_title(root: &Value, focus: &[Seg]) -> Option<String> {
962    let Some(Seg::Index(i)) = focus.last() else {
963        return None;
964    };
965    let Value::Seq(items) = value_at(root, &focus[..focus.len() - 1])? else {
966        return None;
967    };
968    title_of(&title_keys(items), items.get(*i)?)
969}
970
971fn item(
972    label: String,
973    path: Vec<Seg>,
974    v: &Value,
975    kind: ItemKind,
976    inset: usize,
977    title: Option<String>,
978    demoted: bool,
979) -> PageItem {
980    let descend_to = path.clone();
981    PageItem {
982        path,
983        descend_to,
984        label,
985        vkind: VKind::of(v),
986        preview: preview(v),
987        kind,
988        inset,
989        title,
990        demoted,
991        summary: is_container(v).then(|| flow(v, SUMMARY_BUDGET)).flatten(),
992    }
993}
994
995/// The (label, path, value) of each child of a container, in document order.
996/// Empty for a scalar.
997fn children_of<'v>(node: &'v Value, base: &[Seg]) -> Vec<(String, Vec<Seg>, &'v Value)> {
998    let extend = |seg: Seg| {
999        let mut p = base.to_vec();
1000        p.push(seg);
1001        p
1002    };
1003    match node {
1004        Value::Map(entries) => entries
1005            .iter()
1006            .map(|(k, v)| {
1007                let key = key_to_string(k);
1008                (key.clone(), extend(Seg::Key(key)), v)
1009            })
1010            .collect(),
1011        Value::Seq(items) => items
1012            .iter()
1013            .enumerate()
1014            .map(|(i, v)| (format!("[{i}]"), extend(Seg::Index(i)), v))
1015            .collect(),
1016        _ => Vec::new(),
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use fig::Format;
1024
1025    const SAMPLE: &str = "\
1026title = \"flower\"
1027version = 1
1028enabled = true
1029
1030[server]
1031host = \"localhost\"
1032port = 8080
1033tags = [\"alpha\", \"beta\"]
1034
1035[server.limits]
1036max_connections = 100
1037timeout = 30.5
1038";
1039
1040    fn value_of(src: &str, fmt: Format) -> Value {
1041        fig::Document::parse(src.as_bytes(), fmt)
1042            .expect("parse")
1043            .to_value()
1044            .expect("to_value")
1045    }
1046
1047    fn sample() -> Value {
1048        value_of(SAMPLE, Format::Toml)
1049    }
1050
1051    fn page_of(root: &Value, focus: &[Seg]) -> Page {
1052        build_page(
1053            root,
1054            focus,
1055            &HashSet::new(),
1056            &HashSet::new(),
1057            InlineBudget::default(),
1058        )
1059    }
1060
1061    fn budgeted(root: &Value, focus: &[Seg], budget: InlineBudget) -> Page {
1062        build_page(root, focus, &HashSet::new(), &HashSet::new(), budget)
1063    }
1064
1065    fn demoting(root: &Value, focus: &[Seg], demoted: &[&str]) -> Page {
1066        let set: HashSet<String> = demoted.iter().map(|s| s.to_string()).collect();
1067        build_page(root, focus, &HashSet::new(), &set, InlineBudget::default())
1068    }
1069
1070    fn key(k: &str) -> Seg {
1071        Seg::Key(k.to_string())
1072    }
1073
1074    /// `label`, `inset`, and what activating it does — the whole shape of a page
1075    /// in one comparable form.
1076    fn shape(page: &Page) -> Vec<(String, usize, &'static str)> {
1077        page.items
1078            .iter()
1079            .map(|i| {
1080                let kind = match i.kind {
1081                    ItemKind::Scalar => "scalar",
1082                    ItemKind::Drill { .. } => "drill",
1083                    ItemKind::GroupHeader { .. } => "group",
1084                };
1085                (i.label.clone(), i.inset, kind)
1086            })
1087            .collect()
1088    }
1089
1090    #[test]
1091    fn the_root_page_lists_one_level_and_drills_the_rest() {
1092        let root = sample();
1093        assert_eq!(
1094            shape(&page_of(&root, &[])),
1095            vec![
1096                ("title".into(), 0, "scalar"),
1097                ("version".into(), 0, "scalar"),
1098                ("enabled".into(), 0, "scalar"),
1099                // Mixed children (two containers among four) — a page of its own.
1100                ("server".into(), 0, "drill"),
1101            ]
1102        );
1103    }
1104
1105    #[test]
1106    fn small_all_scalar_containers_inline_into_the_page() {
1107        let root = sample();
1108        // `tags` (2 strings) and `limits` (2 numbers) are both small and entirely
1109        // scalar, so `server` renders as one page rather than three.
1110        assert_eq!(
1111            shape(&page_of(&root, &[key("server")])),
1112            vec![
1113                ("host".into(), 0, "scalar"),
1114                ("port".into(), 0, "scalar"),
1115                ("tags".into(), 0, "group"),
1116                ("[0]".into(), 1, "scalar"),
1117                ("[1]".into(), 1, "scalar"),
1118                ("limits".into(), 0, "group"),
1119                ("max_connections".into(), 1, "scalar"),
1120                ("timeout".into(), 1, "scalar"),
1121            ]
1122        );
1123    }
1124
1125    #[test]
1126    fn an_inlined_member_keeps_its_own_path() {
1127        let root = sample();
1128        let page = page_of(&root, &[key("server")]);
1129        let timeout = page
1130            .items
1131            .iter()
1132            .find(|i| i.label == "timeout")
1133            .expect("timeout on server's page");
1134        // The path is the document's, not the page's — an edit op takes it as-is
1135        // even though the row is two ranks below the page's focus.
1136        assert_eq!(
1137            timeout.path,
1138            vec![key("server"), key("limits"), key("timeout")]
1139        );
1140    }
1141
1142    #[test]
1143    fn a_container_too_big_to_inline_drills() {
1144        let mut src = String::from("[big]\n");
1145        for i in 0..=INLINE_MAX {
1146            src.push_str(&format!("k{i} = {i}\n"));
1147        }
1148        let root = value_of(&src, Format::Toml);
1149        assert_eq!(
1150            shape(&page_of(&root, &[])),
1151            vec![("big".into(), 0, "drill")]
1152        );
1153
1154        // One fewer child and the same container inlines.
1155        let trimmed = src
1156            .rsplit_once('\n')
1157            .unwrap()
1158            .0
1159            .rsplit_once('\n')
1160            .unwrap()
1161            .0;
1162        let root = value_of(&format!("{trimmed}\n"), Format::Toml);
1163        assert_eq!(page_of(&root, &[]).items[0].inset, 0);
1164        assert!(matches!(
1165            page_of(&root, &[]).items[0].kind,
1166            ItemKind::GroupHeader { .. }
1167        ));
1168    }
1169
1170    #[test]
1171    fn a_container_holding_a_container_drills_however_small() {
1172        let root = value_of("{\"a\": {\"b\": {\"c\": 1}}}", Format::Json);
1173        // `a` has one child — but that child is a container, so inlining it would
1174        // put a group inside a group and reintroduce unbounded depth.
1175        assert_eq!(shape(&page_of(&root, &[])), vec![("a".into(), 0, "drill")]);
1176        assert_eq!(
1177            shape(&page_of(&root, &[key("a")])),
1178            vec![("b".into(), 0, "group"), ("c".into(), 1, "scalar")]
1179        );
1180    }
1181
1182    // ── the page's own row limit ──────────────────────────────────────────
1183
1184    /// A list of entries that each inline comfortably and are numerous enough
1185    /// that inlining all of them buries the page — the `repos.figl` shape.
1186    fn list_of(n: usize) -> Value {
1187        let items: Vec<String> = (0..n)
1188            .map(|i| format!(r#"{{"name": "r{i}", "lang": "rust"}}"#))
1189            .collect();
1190        value_of(
1191            &format!(r#"{{"repo": [{}]}}"#, items.join(", ")),
1192            Format::Json,
1193        )
1194    }
1195
1196    #[test]
1197    fn a_list_long_enough_to_bury_the_page_is_listed_rather_than_expanded() {
1198        // Every item passes the per-item budget: two scalars, one rank. The
1199        // page still refuses them, because eight of them are twenty-four rows.
1200        let root = list_of(8);
1201        assert!(inlines(
1202            &value_of(r#"{"name": "r0", "lang": "rust"}"#, Format::Json),
1203            InlineBudget::default()
1204        ));
1205        let page = page_of(&root, &[key("repo")]);
1206        assert!(
1207            page.items.iter().all(PageItem::is_drill),
1208            "{:?}",
1209            shape(&page)
1210        );
1211        assert_eq!(page.items.len(), 8);
1212    }
1213
1214    #[test]
1215    fn the_page_limit_is_asked_once_per_page_not_once_per_item() {
1216        let root = list_of(8);
1217        // Room for all twenty-four rows — a header and two fields apiece — and
1218        // the same list inlines. Nothing about the items changed, only what the
1219        // page can afford.
1220        let roomy = InlineBudget {
1221            page_rows: 24,
1222            ..InlineBudget::default()
1223        };
1224        let page = budgeted(&root, &[key("repo")], roomy);
1225        assert_eq!(page.items.iter().filter(|i| i.inset == 0).count(), 8);
1226        assert!(!page.has_drills(), "{:?}", shape(&page));
1227
1228        // One row short of the total is a no, and it is a no for every item:
1229        // a list renders uniformly or not at all.
1230        let tight = InlineBudget {
1231            page_rows: 23,
1232            ..InlineBudget::default()
1233        };
1234        let page = budgeted(&root, &[key("repo")], tight);
1235        assert!(page.items.iter().all(PageItem::is_drill));
1236    }
1237
1238    #[test]
1239    fn a_mapping_is_not_held_to_the_page_limit() {
1240        // Two groups of three, under a page limit that a sequence of the same
1241        // size would fail. A mapping's children are decided one at a time, so
1242        // holding them to a running total would inline whichever key came
1243        // first — a page whose shape depends on key order.
1244        let root = value_of(
1245            r#"{"a": {"x": 1, "y": 2, "z": 3}, "b": {"x": 1, "y": 2, "z": 3}}"#,
1246            Format::Json,
1247        );
1248        let page = budgeted(
1249            &root,
1250            &[],
1251            InlineBudget {
1252                page_rows: 4,
1253                ..InlineBudget::default()
1254            },
1255        );
1256        assert!(!page.has_drills(), "{:?}", shape(&page));
1257    }
1258
1259    #[test]
1260    fn a_long_list_of_scalars_is_still_just_its_items() {
1261        // The page limit governs what a list *expands*; a sequence of scalars
1262        // has nothing to expand, and one row each is the only rendering there
1263        // is however many of them there are.
1264        let items: Vec<String> = (0..40).map(|i| i.to_string()).collect();
1265        let root = value_of(&format!("{{\"ns\": [{}]}}", items.join(", ")), Format::Json);
1266        let page = page_of(&root, &[key("ns")]);
1267        assert_eq!(page.items.len(), 40);
1268        assert!(page.items.iter().all(PageItem::is_scalar));
1269    }
1270
1271    // ── fitting a budget to the room ──────────────────────────────────────
1272
1273    #[test]
1274    fn a_document_that_fits_the_room_needs_no_navigation_at_all() {
1275        let root = sample();
1276        // Twelve rows of document. Given twelve rows of room, the root page is
1277        // the document and there is nothing left to open.
1278        let page = budgeted(&root, &[], InlineBudget::fitting(&root, 12));
1279        assert_eq!(page.items.len(), 12);
1280        assert!(!page.has_drills(), "{:?}", shape(&page));
1281    }
1282
1283    #[test]
1284    fn a_document_one_row_too_big_falls_back_to_the_founding_rule() {
1285        let root = sample();
1286        let budget = InlineBudget::fitting(&root, 11);
1287        assert_eq!(budget.rows, INLINE_MAX);
1288        assert_eq!(budget.depth, 1);
1289        // The room still sets the page's own limit, so a taller terminal
1290        // inlines a longer list and a short one does not.
1291        assert_eq!(budget.page_rows, 11);
1292        assert!(budgeted(&root, &[], budget).has_drills());
1293    }
1294
1295    #[test]
1296    fn a_document_too_deep_to_read_drills_however_short_it_is() {
1297        // Four rows and four ranks. It fits the room by the row count and not
1298        // by the eye: inlining it would draw eight columns of inset.
1299        let root = value_of(r#"{"a": {"b": {"c": {"d": 1}}}}"#, Format::Json);
1300        let budget = InlineBudget::fitting(&root, 100);
1301        assert_eq!(budget.depth, 1);
1302        assert!(budgeted(&root, &[], budget).has_drills());
1303    }
1304
1305    #[test]
1306    fn a_room_of_nothing_still_leaves_the_founding_rule_intact() {
1307        // A terminal too short to draw anything is not a reason to stop
1308        // inlining the small groups the founding rule was written for.
1309        let budget = InlineBudget::fitting(&sample(), 0);
1310        assert_eq!(budget.page_rows, INLINE_MAX);
1311        assert_eq!(
1312            shape(&budgeted(&sample(), &[key("server")], budget)),
1313            shape(&page_of(&sample(), &[key("server")]))
1314        );
1315    }
1316
1317    #[test]
1318    fn raising_the_subtree_limit_raises_the_page_limit_with_it() {
1319        // A caller asking for a hundred rows of subtree is asking for a page
1320        // that can hold them.
1321        assert_eq!(InlineBudget::new(99, 8).page_rows, 99);
1322        // And one asking for less than a page's worth does not lower it.
1323        assert_eq!(InlineBudget::new(2, 1).page_rows, PAGE_INLINE_MAX);
1324    }
1325
1326    #[test]
1327    fn a_page_of_one_row_is_a_label_rather_than_a_choice() {
1328        let root = value_of(
1329            r#"{"repo": [{"a": 1, "b": 2, "c": 3, "d": 4}]}"#,
1330            Format::Json,
1331        );
1332        assert!(!page_of(&root, &[]).has_choice());
1333        assert!(page_of(&root, &[key("repo"), Seg::Index(0)]).has_choice());
1334    }
1335
1336    // ── the inline budget ─────────────────────────────────────────────────
1337
1338    #[test]
1339    fn a_deeper_budget_inlines_a_nested_container_rank_by_rank() {
1340        let root = value_of("{\"a\": {\"b\": {\"c\": 1}}}", Format::Json);
1341        // `a`'s subtree reaches two ranks below its header — `b`, then `c`
1342        // under it — so a depth of 2 admits the whole chain onto the root page.
1343        let page = budgeted(&root, &[], InlineBudget::new(6, 2));
1344        assert_eq!(
1345            shape(&page),
1346            vec![
1347                ("a".into(), 0, "group"),
1348                ("b".into(), 1, "group"),
1349                ("c".into(), 2, "scalar"),
1350            ]
1351        );
1352        // One rank shy and it drills exactly as the default does.
1353        let page = budgeted(&root, &[], InlineBudget::new(6, 1));
1354        assert_eq!(shape(&page), vec![("a".into(), 0, "drill")]);
1355    }
1356
1357    #[test]
1358    fn the_row_limit_counts_the_whole_subtree_headers_included() {
1359        let root = value_of(
1360            r#"{"outer": {"g": {"x": 1, "y": 2}, "z": 3}}"#,
1361            Format::Json,
1362        );
1363        // `outer` costs four rows: `g`'s header, its two members, and `z`.
1364        let fits = InlineBudget::new(4, 2);
1365        assert!(matches!(
1366            budgeted(&root, &[], fits).items[0].kind,
1367            ItemKind::GroupHeader { .. }
1368        ));
1369        let short = InlineBudget::new(3, 2);
1370        assert!(budgeted(&root, &[], short).items[0].is_drill());
1371    }
1372
1373    #[test]
1374    fn a_generous_budget_puts_the_whole_document_on_the_root_page() {
1375        // The absorbed settings list: raise the budget past the document's size
1376        // and the root page simply is the document, ranks drawn as insets.
1377        let root = sample();
1378        let page = budgeted(&root, &[], InlineBudget::new(99, 8));
1379        assert_eq!(
1380            shape(&page),
1381            vec![
1382                ("title".into(), 0, "scalar"),
1383                ("version".into(), 0, "scalar"),
1384                ("enabled".into(), 0, "scalar"),
1385                ("server".into(), 0, "group"),
1386                ("host".into(), 1, "scalar"),
1387                ("port".into(), 1, "scalar"),
1388                ("tags".into(), 1, "group"),
1389                ("[0]".into(), 2, "scalar"),
1390                ("[1]".into(), 2, "scalar"),
1391                ("limits".into(), 1, "group"),
1392                ("max_connections".into(), 2, "scalar"),
1393                ("timeout".into(), 2, "scalar"),
1394            ]
1395        );
1396        assert!(!page.has_drills(), "nothing left to navigate to");
1397    }
1398
1399    #[test]
1400    fn an_inlined_subtree_keeps_every_paths_own_address() {
1401        let root = sample();
1402        let page = budgeted(&root, &[], InlineBudget::new(99, 8));
1403        let timeout = page
1404            .items
1405            .iter()
1406            .find(|i| i.label == "timeout")
1407            .expect("timeout inlined onto the root page");
1408        assert_eq!(
1409            timeout.path,
1410            vec![key("server"), key("limits"), key("timeout")]
1411        );
1412    }
1413
1414    #[test]
1415    fn a_budget_that_admits_a_chain_inlines_it_instead_of_compressing() {
1416        let root = value_of(LONE, Format::Json);
1417        let page = budgeted(&root, &[], InlineBudget::new(99, 8));
1418        let exports = &page.items[0];
1419        // Under the default budget this row compresses to `exports › journal`;
1420        // with room for the whole subtree there is no page to skip.
1421        assert!(matches!(exports.kind, ItemKind::GroupHeader { .. }));
1422        assert!(!exports.is_compressed());
1423    }
1424
1425    #[test]
1426    fn a_sequence_of_nested_mappings_inlines_uniformly_under_a_deep_budget() {
1427        let root = value_of(STEPS, Format::Json);
1428        // The third step nests a `with` mapping, which the default budget's one
1429        // rank refuses — and uniformity then drills every item. Two ranks admit
1430        // it, so the whole list inlines, titles on the item headers.
1431        let page = budgeted(&root, &[key("steps")], InlineBudget::new(20, 2));
1432        let headers: Vec<_> = page
1433            .items
1434            .iter()
1435            .filter(|i| i.inset == 0)
1436            .map(|i| {
1437                (
1438                    i.title.clone(),
1439                    matches!(i.kind, ItemKind::GroupHeader { .. }),
1440                )
1441            })
1442            .collect();
1443        assert_eq!(headers.len(), 4);
1444        assert!(headers.iter().all(|(_, is_group)| *is_group));
1445        assert_eq!(headers[0].0.as_deref(), Some("actions/checkout@v7"));
1446        // The nested `with` renders as a group one rank further in.
1447        let with = page.items.iter().find(|i| i.label == "with").expect("with");
1448        assert_eq!(with.inset, 1);
1449        assert!(matches!(with.kind, ItemKind::GroupHeader { .. }));
1450    }
1451
1452    #[test]
1453    fn demotion_still_folds_a_deeply_inlined_subtree_in_one_run() {
1454        let root = sample();
1455        let set: HashSet<String> = ["server".to_string()].into();
1456        let page = build_page(&root, &[], &HashSet::new(), &set, InlineBudget::new(99, 8));
1457        let (primary, advanced) = page.partitioned();
1458        assert_eq!(
1459            primary.iter().map(|i| i.label.as_str()).collect::<Vec<_>>(),
1460            ["title", "version", "enabled"]
1461        );
1462        // The whole inlined subtree is one contiguous demoted run — the fold
1463        // cannot cut a group in half however deep the budget let it nest.
1464        assert_eq!(advanced.len(), 9);
1465        assert!(advanced.iter().all(|i| i.demoted));
1466    }
1467
1468    #[test]
1469    fn an_empty_container_drills_rather_than_inlining_as_a_headless_group() {
1470        let root = value_of("{\"empty\": {}, \"none\": []}", Format::Json);
1471        assert_eq!(
1472            shape(&page_of(&root, &[])),
1473            vec![("empty".into(), 0, "drill"), ("none".into(), 0, "drill")]
1474        );
1475        assert!(page_of(&root, &[key("empty")]).is_empty());
1476    }
1477
1478    #[test]
1479    fn hiding_is_scoped_to_the_root_page() {
1480        let root = value_of(
1481            "{\"id\": 1, \"inner\": {\"id\": 2, \"keep\": 3}}",
1482            Format::Json,
1483        );
1484        let hidden = HashSet::from(["id".to_string()]);
1485        let rooted = build_page(
1486            &root,
1487            &[],
1488            &hidden,
1489            &HashSet::new(),
1490            InlineBudget::default(),
1491        );
1492        assert_eq!(
1493            shape(&rooted),
1494            vec![
1495                ("inner".into(), 0, "group"),
1496                ("id".into(), 1, "scalar"),
1497                ("keep".into(), 1, "scalar")
1498            ]
1499        );
1500        // The nested `id` shares the name and is untouched — the group inlined
1501        // into the root page still carries it.
1502        let inner = build_page(
1503            &root,
1504            &[key("inner")],
1505            &hidden,
1506            &HashSet::new(),
1507            InlineBudget::default(),
1508        );
1509        assert_eq!(
1510            shape(&inner),
1511            vec![("id".into(), 0, "scalar"), ("keep".into(), 0, "scalar")]
1512        );
1513    }
1514
1515    #[test]
1516    fn a_page_that_cannot_be_listed_is_empty_rather_than_a_panic() {
1517        let root = sample();
1518        assert!(page_of(&root, &[key("nope")]).is_empty());
1519        assert!(page_of(&root, &[key("title")]).is_empty());
1520    }
1521
1522    #[test]
1523    fn breadcrumbs_name_the_lineage() {
1524        let root = sample();
1525        assert_eq!(page_of(&root, &[]).breadcrumb("‹document›"), "‹document›");
1526        assert_eq!(
1527            page_of(&root, &[key("server"), key("limits")]).breadcrumb("‹document›"),
1528            "server › limits"
1529        );
1530        assert_eq!(
1531            page_of(&root, &[key("server"), key("tags")]).breadcrumb("x"),
1532            "server › tags"
1533        );
1534    }
1535
1536    #[test]
1537    fn a_flat_document_has_nothing_to_drill_into() {
1538        let flat = value_of("{\"a\": 1, \"b\": 2}", Format::Json);
1539        assert!(!page_of(&flat, &[]).has_drills());
1540        assert!(page_of(&sample(), &[]).has_drills());
1541    }
1542
1543    // ── titles for sequence items ─────────────────────────────────────────
1544
1545    /// A workflow's steps: the case with no single naming key. Different kinds of
1546    /// step are named by different fields, and one field (`if`) reads the same on
1547    /// the items that have it.
1548    const STEPS: &str = r#"{"steps": [
1549        {"uses": "actions/checkout@v7"},
1550        {"uses": "dtolnay/rust-toolchain@stable", "if": "always"},
1551        {"uses": "Swatinem/rust-cache@v2", "if": "always", "with": {"key": "a"}},
1552        {"run": "cargo xtask ci", "shell": "bash"}
1553    ]}"#;
1554
1555    fn titles(page: &Page) -> Vec<Option<String>> {
1556        page.items.iter().map(|i| i.title.clone()).collect()
1557    }
1558
1559    #[test]
1560    fn a_sequence_item_is_titled_by_the_field_that_distinguishes_it() {
1561        let root = value_of(STEPS, Format::Json);
1562        let page = page_of(&root, &[key("steps")]);
1563        assert_eq!(
1564            titles(&page),
1565            vec![
1566                Some("actions/checkout@v7".into()),
1567                Some("dtolnay/rust-toolchain@stable".into()),
1568                Some("Swatinem/rust-cache@v2".into()),
1569                // No `uses` at all — falls to the next-best key it does have.
1570                Some("cargo xtask ci".into()),
1571            ]
1572        );
1573    }
1574
1575    #[test]
1576    fn a_key_that_reads_the_same_on_every_item_loses_to_one_that_does_not() {
1577        let root = value_of(STEPS, Format::Json);
1578        let Value::Map(entries) = &root else {
1579            unreachable!()
1580        };
1581        let Value::Seq(items) = &entries[0].1 else {
1582            unreachable!()
1583        };
1584        let ranking = title_keys(items);
1585        // `if` is on two items and says "always" on both, so it names neither.
1586        let uses = ranking.iter().position(|k| k == "uses").expect("uses");
1587        let cond = ranking.iter().position(|k| k == "if").expect("if");
1588        assert!(uses < cond, "{ranking:?}");
1589        // `with` is a container: never a title.
1590        assert!(!ranking.iter().any(|k| k == "with"), "{ranking:?}");
1591    }
1592
1593    #[test]
1594    fn a_conventional_name_key_outranks_a_merely_distinct_one() {
1595        let root = value_of(
1596            r#"{"env": [
1597                {"name": "HOME", "value": "/root"},
1598                {"name": "PATH", "value": "/bin"}
1599            ]}"#,
1600            Format::Json,
1601        );
1602        let page = page_of(&root, &[key("env")]);
1603        // Both items are small and all-scalar, so they inline — and the title
1604        // lands on the group header, which is the row standing in for the item.
1605        // `value` is exactly as distinct and as well covered as `name`; `name`
1606        // wins because it is what a config author means by a name.
1607        assert_eq!(
1608            page.items
1609                .iter()
1610                .filter(|i| i.inset == 0)
1611                .map(|i| i.title.clone())
1612                .collect::<Vec<_>>(),
1613            vec![Some("HOME".into()), Some("PATH".into())]
1614        );
1615    }
1616
1617    #[test]
1618    fn a_mapping_entry_is_never_titled() {
1619        let root = sample();
1620        assert!(page_of(&root, &[]).items.iter().all(|i| i.title.is_none()));
1621        // Nor is a sequence of scalars: the value is already the whole row.
1622        let tags = page_of(&root, &[key("server"), key("tags")]);
1623        assert!(tags.items.iter().all(|i| i.title.is_none()));
1624    }
1625
1626    #[test]
1627    fn a_sequence_renders_its_items_uniformly() {
1628        let root = value_of(STEPS, Format::Json);
1629        let page = page_of(&root, &[key("steps")]);
1630        // The third step nests a `with` mapping, so it cannot inline — and none of
1631        // the others do either, however small. A list reads as a list.
1632        assert!(
1633            page.items
1634                .iter()
1635                .all(|i| matches!(i.kind, ItemKind::Drill { .. })),
1636            "{:?}",
1637            shape(&page)
1638        );
1639
1640        // Take the nesting away and every item inlines, again as a group.
1641        let flat = value_of(
1642            r#"{"steps": [{"run": "a"}, {"run": "b", "shell": "sh"}]}"#,
1643            Format::Json,
1644        );
1645        let page = page_of(&flat, &[key("steps")]);
1646        assert_eq!(
1647            shape(&page),
1648            vec![
1649                ("[0]".into(), 0, "group"),
1650                ("run".into(), 1, "scalar"),
1651                ("[1]".into(), 0, "group"),
1652                ("run".into(), 1, "scalar"),
1653                ("shell".into(), 1, "scalar"),
1654            ]
1655        );
1656    }
1657
1658    #[test]
1659    fn a_titled_item_carries_its_title_into_its_own_breadcrumb() {
1660        let root = value_of(STEPS, Format::Json);
1661        let page = page_of(&root, &[key("steps"), Seg::Index(3)]);
1662        assert_eq!(page.title.as_deref(), Some("cargo xtask ci"));
1663        assert_eq!(page.breadcrumb("‹document›"), "steps › cargo xtask ci");
1664        // Its own children are mapping entries, so none of them is titled.
1665        assert!(page.items.iter().all(|i| i.title.is_none()));
1666    }
1667
1668    #[test]
1669    fn a_multi_line_value_is_cut_to_its_first_line() {
1670        let root = value_of(
1671            "{\"steps\": [{\"run\": \"set -e\\ncargo test\\n\"}]}",
1672            Format::Json,
1673        );
1674        let page = page_of(&root, &[key("steps")]);
1675        // A YAML block scalar would otherwise draw a row several lines tall and
1676        // throw every row below it out of alignment.
1677        assert_eq!(page.items[0].title.as_deref(), Some("set -e …"));
1678        assert!(!page.items[0].preview.contains('\n'));
1679    }
1680
1681    // ── flow summaries ────────────────────────────────────────────────────
1682
1683    #[test]
1684    fn a_container_that_fits_on_the_row_shows_its_contents_not_a_count() {
1685        let root = value_of(
1686            r#"{"on": {"push": {"branches": ["main"]}, "pull_request": null}}"#,
1687            Format::Json,
1688        );
1689        let page = page_of(&root, &[key("on")]);
1690        let push = &page.items[0];
1691        // It has one field, and the field is right there: counting it to `1 field`
1692        // would say strictly less than the document does in the same room.
1693        assert!(matches!(push.kind, ItemKind::Drill { count: 1 }));
1694        assert_eq!(push.summary.as_deref(), Some("{branches: [main]}"));
1695    }
1696
1697    #[test]
1698    fn a_container_too_long_to_summarise_falls_back_to_being_counted() {
1699        let long = "x".repeat(SUMMARY_BUDGET);
1700        let root = value_of(
1701            &format!(r#"{{"outer": {{"a": {{"b": "{long}"}}}}}}"#),
1702            Format::Json,
1703        );
1704        let page = page_of(&root, &[key("outer")]);
1705        assert!(page.items[0].summary.is_none());
1706        // The budget is a length limit, not a depth one — shorten the value and
1707        // the same shape summarises fine.
1708        let root = value_of(r#"{"outer": {"a": {"b": "x"}}}"#, Format::Json);
1709        assert_eq!(
1710            page_of(&root, &[key("outer")]).items[0].summary.as_deref(),
1711            Some("{b: x}")
1712        );
1713    }
1714
1715    #[test]
1716    fn a_titled_row_does_not_spend_its_width_saying_its_title_twice() {
1717        let root = list_of(8);
1718        let page = page_of(&root, &[key("repo")]);
1719        let first = &page.items[0];
1720        // The row already reads `[0] · r0`; a summary opening `name: r0` would
1721        // say it again in the same line.
1722        assert_eq!(first.title.as_deref(), Some("r0"));
1723        assert_eq!(first.summary.as_deref(), Some("{lang: rust}"));
1724        // Elided from the summary, not from the document: the count still
1725        // counts it, and it is on the page the row opens.
1726        assert!(matches!(first.kind, ItemKind::Drill { count: 2 }));
1727        assert_eq!(
1728            page_of(&root, &[key("repo"), Seg::Index(0)])
1729                .items
1730                .iter()
1731                .map(|i| i.label.as_str())
1732                .collect::<Vec<_>>(),
1733            ["name", "lang"]
1734        );
1735    }
1736
1737    #[test]
1738    fn a_row_whose_only_field_is_its_title_falls_back_to_being_counted() {
1739        // Nothing left once the title is elided, and `{}` would be a lie about
1740        // a mapping that has a field in it. The count says the rest.
1741        let items: Vec<String> = (0..8).map(|i| format!(r#"{{"name": "r{i}"}}"#)).collect();
1742        let root = value_of(
1743            &format!(r#"{{"repo": [{}]}}"#, items.join(", ")),
1744            Format::Json,
1745        );
1746        let page = budgeted(
1747            &root,
1748            &[key("repo")],
1749            InlineBudget {
1750                page_rows: 4,
1751                ..InlineBudget::default()
1752            },
1753        );
1754        assert_eq!(page.items[0].title.as_deref(), Some("r0"));
1755        assert!(page.items[0].summary.is_none());
1756        assert!(matches!(page.items[0].kind, ItemKind::Drill { count: 1 }));
1757    }
1758
1759    #[test]
1760    fn compression_and_a_title_cannot_meet() {
1761        // What lets the elision summarise `child` without checking whether the
1762        // row compressed past it. Compression needs a container holding one
1763        // container and nothing else; a title needs a scalar field. A row can
1764        // have either and never both — so a compressed row's summary is of the
1765        // node its title would have come from, vacuously.
1766        let root = value_of(
1767            r#"{"repo": [{"only": {"name": "inner", "lang": "rust", "a": 1,
1768                                   "b": 2, "c": 3, "d": 4, "e": 5}}]}"#,
1769            Format::Json,
1770        );
1771        let row = &page_of(&root, &[key("repo")]).items[0];
1772        assert!(row.is_compressed(), "{:?}", row.chain_labels());
1773        assert_eq!(row.chain_labels(), ["[0]", "only"]);
1774        // Nothing elided, because there was no title to elide.
1775        assert!(row.title.is_none());
1776        assert!(
1777            row.summary
1778                .as_deref()
1779                .is_some_and(|f| f.starts_with("{name: inner")),
1780            "{:?}",
1781            row.summary
1782        );
1783    }
1784
1785    #[test]
1786    fn a_mapping_entry_summarises_whole_because_nothing_titled_it() {
1787        // Only a sequence item takes a title, so only a sequence item has one
1788        // to elide. A `name` under a key is just a field.
1789        let root = value_of(r#"{"a": {"name": "x", "lang": "rust"}}"#, Format::Json);
1790        let page = budgeted(
1791            &root,
1792            &[],
1793            InlineBudget {
1794                rows: 1,
1795                ..InlineBudget::default()
1796            },
1797        );
1798        assert_eq!(
1799            page.items[0].summary.as_deref(),
1800            Some("{name: x, lang: rust}")
1801        );
1802    }
1803
1804    #[test]
1805    fn a_scalar_is_never_summarised() {
1806        let root = sample();
1807        let page = page_of(&root, &[]);
1808        assert!(
1809            page.items
1810                .iter()
1811                .filter(|i| matches!(i.kind, ItemKind::Scalar))
1812                .all(|i| i.summary.is_none())
1813        );
1814    }
1815
1816    #[test]
1817    fn a_group_header_is_not_a_drill() {
1818        let root = sample();
1819        let page = page_of(&root, &[key("server")]);
1820        let limits = page
1821            .items
1822            .iter()
1823            .find(|i| i.label == "limits")
1824            .expect("limits");
1825        assert!(matches!(limits.kind, ItemKind::GroupHeader { .. }));
1826        // It names a container, but opening it would show what is already here.
1827        assert!(limits.is_container());
1828        assert!(!limits.is_drill());
1829        assert!(!page.has_drills());
1830    }
1831
1832    #[test]
1833    fn a_demoted_key_is_marked_but_still_listed_in_document_order() {
1834        let root = sample();
1835        let page = demoting(&root, &[], &["version"]);
1836        // Demotion is not hiding: the row is still there, still where the
1837        // document put it. Only `demoted` moved.
1838        assert_eq!(
1839            shape(&page)
1840                .iter()
1841                .map(|(l, _, _)| l.as_str())
1842                .collect::<Vec<_>>(),
1843            ["title", "version", "enabled", "server"]
1844        );
1845        let demoted: Vec<&str> = page
1846            .items
1847            .iter()
1848            .filter(|i| i.demoted)
1849            .map(|i| i.label.as_str())
1850            .collect();
1851        assert_eq!(demoted, ["version"]);
1852    }
1853
1854    #[test]
1855    fn partitioning_folds_the_demoted_run_to_the_end_and_keeps_both_orders() {
1856        let root = sample();
1857        let page = demoting(&root, &[], &["title", "server"]);
1858        let (primary, advanced) = page.partitioned();
1859        assert_eq!(
1860            primary.iter().map(|i| i.label.as_str()).collect::<Vec<_>>(),
1861            ["version", "enabled"]
1862        );
1863        assert_eq!(
1864            advanced
1865                .iter()
1866                .map(|i| i.label.as_str())
1867                .collect::<Vec<_>>(),
1868            ["title", "server"]
1869        );
1870    }
1871
1872    #[test]
1873    fn demotion_covers_the_whole_subtree_so_drilling_in_stays_demoted() {
1874        let root = sample();
1875        // The row on the root's page.
1876        let at_root = demoting(&root, &[], &["server"]);
1877        assert!(
1878            at_root
1879                .items
1880                .iter()
1881                .find(|i| i.label == "server")
1882                .unwrap()
1883                .demoted
1884        );
1885
1886        // The page that row opens, and everything on it — including the members
1887        // of a group inlined into it, which are two segments deeper still.
1888        let inside = demoting(&root, &[key("server")], &["server"]);
1889        assert!(inside.demoted);
1890        assert!(inside.items.iter().all(|i| i.demoted));
1891        assert!(inside.items.iter().any(|i| i.inset == 1));
1892
1893        let deeper = demoting(&root, &[key("server"), key("limits")], &["server"]);
1894        assert!(deeper.demoted);
1895        assert!(deeper.items.iter().all(|i| i.demoted));
1896    }
1897
1898    #[test]
1899    fn demotion_is_root_scoped_so_a_nested_key_of_the_same_name_is_untouched() {
1900        // `host` is demoted at the root; the `host` *inside* `server` is a
1901        // different field that happens to share a spelling.
1902        let root = value_of(
1903            r#"{"host": "managed", "server": {"host": "localhost", "port": 8080}}"#,
1904            Format::Json,
1905        );
1906        let page = demoting(&root, &[], &["host"]);
1907        assert!(
1908            page.items
1909                .iter()
1910                .find(|i| i.label == "host")
1911                .unwrap()
1912                .demoted
1913        );
1914        assert!(
1915            !page
1916                .items
1917                .iter()
1918                .find(|i| i.label == "server")
1919                .unwrap()
1920                .demoted
1921        );
1922
1923        let inside = demoting(&root, &[key("server")], &["host"]);
1924        assert!(!inside.demoted);
1925        assert!(inside.items.iter().all(|i| !i.demoted));
1926    }
1927
1928    #[test]
1929    fn a_group_header_and_its_inlined_members_never_land_on_opposite_sides() {
1930        let root = sample();
1931        let page = demoting(&root, &[key("server")], &["server"]);
1932        let (primary, advanced) = page.partitioned();
1933        // Both the header and the members inlined under it are in the same run,
1934        // so the fold cannot cut the group in half.
1935        assert!(primary.is_empty());
1936        let labels: Vec<&str> = advanced.iter().map(|i| i.label.as_str()).collect();
1937        let header = labels.iter().position(|l| *l == "limits").expect("limits");
1938        assert_eq!(&labels[header..], ["limits", "max_connections", "timeout"]);
1939    }
1940
1941    /// The shape that prompted compression: a category holding one export, which
1942    /// holds a map, so it cannot inline and earns a page with one row on it.
1943    const LONE: &str = r#"{
1944      "exports": {"journal": {"label": "Public Journal",
1945                              "gate": {"field": "audience", "value": "public"}}},
1946      "diaryx": {"publish": {"audiences": [{"name": "public", "gates": []}]}},
1947      "plain": {"a": 1, "b": 2}
1948    }"#;
1949
1950    #[test]
1951    fn a_row_whose_page_would_hold_only_it_names_the_chain_instead() {
1952        let root = value_of(LONE, Format::Json);
1953        let page = page_of(&root, &[]);
1954        let exports = &page.items[0];
1955
1956        assert!(exports.is_compressed());
1957        assert_eq!(exports.chain_labels(), ["exports", "journal"]);
1958        // Described by what it lands on: `journal`'s two fields, not `exports`'
1959        // one.
1960        assert!(matches!(exports.kind, ItemKind::Drill { count: 2 }));
1961        assert_eq!(exports.descend_to, vec![key("exports"), key("journal")]);
1962        // The path is still the outermost node, so every op takes it unchanged.
1963        assert_eq!(exports.path, vec![key("exports")]);
1964    }
1965
1966    #[test]
1967    fn compression_follows_the_chain_as_far_as_it_goes_including_a_lone_seq_item() {
1968        let root = value_of(LONE, Format::Json);
1969        let diaryx = &page_of(&root, &[])
1970            .items
1971            .iter()
1972            .find(|i| i.label == "diaryx")
1973            .expect("diaryx")
1974            .clone();
1975        // diaryx → publish → audiences → [0], and only then a page with two
1976        // things on it.
1977        assert_eq!(
1978            diaryx.chain_labels(),
1979            ["diaryx", "publish", "audiences", "[0]"]
1980        );
1981        assert!(matches!(diaryx.kind, ItemKind::Drill { count: 2 }));
1982    }
1983
1984    #[test]
1985    fn a_page_with_something_to_say_is_never_compressed_past() {
1986        let root = value_of(LONE, Format::Json);
1987        let page = page_of(&root, &[]);
1988        let plain = page.items.iter().find(|i| i.label == "plain").unwrap();
1989        // `plain` holds two scalars, so it inlines — nothing to compress, and
1990        // the group header is not a drill at all.
1991        assert!(!plain.is_compressed());
1992        assert_eq!(plain.chain_labels(), ["plain"]);
1993        assert_eq!(plain.descend_to, plain.path);
1994
1995        // A lone *scalar* child is a real answer too: its page shows a value.
1996        let root = value_of(r#"{"outer": {"only": 1}}"#, Format::Json);
1997        let outer = &page_of(&root, &[]).items[0];
1998        assert!(!outer.is_compressed());
1999    }
2000
2001    #[test]
2002    fn an_uncompressed_row_descends_to_where_it_already_points() {
2003        let root = sample();
2004        for focus in [vec![], vec![key("server")]] {
2005            for item in &page_of(&root, &focus).items {
2006                assert_eq!(item.descend_to, item.path, "{}", item.label);
2007                assert_eq!(item.chain_labels(), std::slice::from_ref(&item.label));
2008            }
2009        }
2010    }
2011
2012    #[test]
2013    fn only_a_mapping_entry_can_be_renamed() {
2014        let root = sample();
2015        let page = page_of(&root, &[key("server"), key("tags")]);
2016        // A sequence item's label is its index — a position, not a name.
2017        assert!(page.items.iter().all(|i| !i.can_rename()));
2018        assert!(page_of(&root, &[]).items.iter().all(|i| i.can_rename()));
2019    }
2020}