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