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