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}