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