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}