Skip to main content

laterite_admin/
nav.rs

1//! The admin's menu: a registry of items that modules, resources and screens
2//! contribute as data, resolved once at boot into a tree and filtered per
3//! request by what the operator may see.
4//!
5//! A top-level item is a tab in the top bar. An item placed under another
6//! makes that one a section: the tab leads to the section's first visible
7//! entry and the entries show in the context sidebar. Every item carries the
8//! permission its target requires, so an operator never sees a tab that would
9//! refuse them.
10
11use laterite_core::Text;
12use serde::{Deserialize, Serialize};
13
14use crate::settings::{CategoryView, ItemView};
15
16/// One menu entry: a tab, or an entry under a section.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18#[serde(deny_unknown_fields, default)]
19#[non_exhaustive]
20pub struct NavItem {
21    /// Unique among every item (`acme.posts`): what another item names in
22    /// `under`.
23    pub key: String,
24    pub label: Text,
25    /// The path under the admin mount (`/posts`). A section may have none: its
26    /// tab then leads to its first visible entry.
27    pub path: Option<String>,
28    /// An icon name from the icon set.
29    pub icon: Option<String>,
30    /// What the operator must hold to see the entry. `None` shows it to every
31    /// signed-in operator; a section with no path shows when any entry does.
32    pub permission: Option<String>,
33    /// The band the entry belongs to (`slot: media`); see
34    /// [`laterite_core::arrange::slot`]. A contributed entry defaults to the
35    /// application band.
36    #[serde(deserialize_with = "laterite_core::arrange::deserialize_slot")]
37    pub slot: i32,
38    /// Sorts among siblings of the band, lowest first; equal orders sort by label.
39    pub order: i32,
40    /// Sits right after the sibling with this key, whatever the bands say.
41    pub after: Option<String>,
42    /// Sits right before the sibling with this key, whatever the bands say.
43    pub before: Option<String>,
44    /// The key of the item this sits under; `None` is the top bar.
45    pub under: Option<String>,
46}
47
48impl laterite_core::arrange::Arranged for NavNode {
49    fn key(&self) -> &str {
50        &self.item.key
51    }
52    fn position(&self) -> &laterite_core::arrange::Position {
53        &self.position
54    }
55    fn label(&self) -> &str {
56        self.item.label.source()
57    }
58}
59
60impl NavItem {
61    /// An entry leading to `path`.
62    pub fn new(key: impl Into<String>, label: impl Into<Text>, path: impl Into<String>) -> Self {
63        Self {
64            key: key.into(),
65            label: label.into(),
66            path: Some(path.into()),
67            icon: None,
68            permission: None,
69            slot: laterite_core::arrange::slot::APPLICATION,
70            order: 0,
71            after: None,
72            before: None,
73            under: None,
74        }
75    }
76
77    /// A section: a tab that holds entries and leads to the first one.
78    pub fn section(key: impl Into<String>, label: impl Into<Text>) -> Self {
79        Self {
80            key: key.into(),
81            label: label.into(),
82            path: None,
83            icon: None,
84            permission: None,
85            slot: laterite_core::arrange::slot::APPLICATION,
86            order: 0,
87            after: None,
88            before: None,
89            under: None,
90        }
91    }
92
93    pub fn icon(mut self, icon: impl Into<String>) -> Self {
94        self.icon = Some(icon.into());
95        self
96    }
97
98    pub fn permission(mut self, permission: impl Into<String>) -> Self {
99        self.permission = Some(permission.into());
100        self
101    }
102
103    pub fn order(mut self, order: i32) -> Self {
104        self.order = order;
105        self
106    }
107
108    /// The band the entry belongs to; see [`laterite_core::arrange::slot`].
109    pub fn slot(mut self, slot: i32) -> Self {
110        self.slot = slot;
111        self
112    }
113
114    /// Places the entry under the section with this key.
115    pub fn under(mut self, section: impl Into<String>) -> Self {
116        self.under = Some(section.into());
117        self
118    }
119
120    /// Sits right after the sibling with this key.
121    pub fn after(mut self, key: impl Into<String>) -> Self {
122        self.after = Some(key.into());
123        self
124    }
125
126    /// Sits right before the sibling with this key.
127    pub fn before(mut self, key: impl Into<String>) -> Self {
128        self.before = Some(key.into());
129        self
130    }
131}
132
133impl Default for NavItem {
134    fn default() -> Self {
135        Self::section("", "")
136    }
137}
138
139/// Where a resource or a screen sits in the menu: under which section, with
140/// which icon, at which position; or out of the menu altogether.
141#[derive(Clone, Debug, Serialize, Deserialize)]
142#[serde(deny_unknown_fields, default)]
143#[non_exhaustive]
144pub struct Placement {
145    /// The key of the section to sit under; `None` is the top bar.
146    pub under: Option<String>,
147    pub icon: Option<String>,
148    /// The band (`slot: media`); the application band when unset.
149    #[serde(deserialize_with = "laterite_core::arrange::deserialize_slot")]
150    pub slot: i32,
151    pub order: i32,
152    /// Sits right after the sibling with this key (a resource's key is
153    /// `resource:` and its path, a screen's `screen:` and its path).
154    pub after: Option<String>,
155    /// Sits right before the sibling with this key.
156    pub before: Option<String>,
157    /// Mounted and reachable by link, with no menu entry.
158    pub hidden: bool,
159}
160
161impl Default for Placement {
162    fn default() -> Self {
163        Self {
164            under: None,
165            icon: None,
166            slot: laterite_core::arrange::slot::APPLICATION,
167            order: 0,
168            after: None,
169            before: None,
170            hidden: false,
171        }
172    }
173}
174
175impl Placement {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Out of the menu: the routes mount, a link elsewhere reaches them.
181    pub fn hidden() -> Self {
182        Self {
183            hidden: true,
184            ..Self::default()
185        }
186    }
187
188    pub fn under(mut self, section: impl Into<String>) -> Self {
189        self.under = Some(section.into());
190        self
191    }
192
193    pub fn icon(mut self, icon: impl Into<String>) -> Self {
194        self.icon = Some(icon.into());
195        self
196    }
197
198    pub fn order(mut self, order: i32) -> Self {
199        self.order = order;
200        self
201    }
202
203    /// The band the entry belongs to; see [`laterite_core::arrange::slot`].
204    pub fn slot(mut self, slot: i32) -> Self {
205        self.slot = slot;
206        self
207    }
208
209    /// Sits right after the sibling with this key.
210    pub fn after(mut self, key: impl Into<String>) -> Self {
211        self.after = Some(key.into());
212        self
213    }
214
215    /// Sits right before the sibling with this key.
216    pub fn before(mut self, key: impl Into<String>) -> Self {
217        self.before = Some(key.into());
218        self
219    }
220}
221
222/// `menu: false` hides; `menu: { under, icon, order, after, before }` places.
223pub(crate) fn deserialize_placement<'de, D>(deserializer: D) -> Result<Placement, D::Error>
224where
225    D: serde::Deserializer<'de>,
226{
227    #[derive(Deserialize)]
228    #[serde(untagged)]
229    enum Key {
230        Flag(bool),
231        Config(Placement),
232    }
233    Ok(match Option::<Key>::deserialize(deserializer)? {
234        Some(Key::Flag(false)) => Placement::hidden(),
235        Some(Key::Flag(true)) | None => Placement::default(),
236        Some(Key::Config(placement)) => placement,
237    })
238}
239
240/// One resolved item: its entries beneath it, paths under the admin mount.
241#[derive(Clone, Debug)]
242pub(crate) struct NavNode {
243    pub item: NavItem,
244    pub children: Vec<NavNode>,
245    /// The item's placement as the arrangement engine reads it.
246    pub position: laterite_core::arrange::Position,
247}
248
249impl NavNode {
250    fn leaf(item: NavItem) -> Self {
251        let mut position = laterite_core::arrange::Position::new()
252            .slot(item.slot)
253            .order(item.order);
254        if let Some(after) = &item.after {
255            position = position.after(after.clone());
256        }
257        if let Some(before) = &item.before {
258            position = position.before(before.clone());
259        }
260        Self {
261            item,
262            children: Vec::new(),
263            position,
264        }
265    }
266}
267
268/// The top bar as a hierarchy: a deployment and an operator may rearrange it,
269/// Dashboard stays first and Settings last.
270pub(crate) fn top_hierarchy() -> laterite_core::arrange::Hierarchy {
271    laterite_core::arrange::Hierarchy::new(TOP_HIERARCHY)
272        .operator_adjustable()
273        .pin_first("dashboard")
274        .pin_last("settings")
275}
276
277/// The configuration key of the top bar.
278pub const TOP_HIERARCHY: &str = "backend.nav";
279
280/// The menu as a tree, built once at boot.
281#[derive(Debug, Default)]
282pub(crate) struct NavTree {
283    pub top: Vec<NavNode>,
284}
285
286impl NavTree {
287    /// Resolves `items` into a tree under `admin_path`. Refuses a duplicate
288    /// key, an entry under a key nobody declared, and an entry under an entry
289    /// (one level of sections).
290    #[cfg(test)]
291    pub(crate) fn build(items: Vec<NavItem>, admin_path: &str) -> Result<Self, String> {
292        Self::build_with(items, admin_path, &std::collections::BTreeMap::new())
293    }
294
295    /// [`build`](Self::build) with a deployment's `[backend.order]`: the top
296    /// bar under `backend.nav`, a section's entries under `backend.nav.<key>`.
297    pub(crate) fn build_with(
298        items: Vec<NavItem>,
299        admin_path: &str,
300        order: &std::collections::BTreeMap<String, Vec<String>>,
301    ) -> Result<Self, String> {
302        use laterite_core::arrange::{arrange, Hierarchy, Overrides};
303        let mut seen = std::collections::HashSet::new();
304        for item in &items {
305            if item.key.is_empty() {
306                return Err(format!("menu item `{}` has no key", item.label.source()));
307            }
308            if !seen.insert(item.key.clone()) {
309                return Err(format!("duplicate menu key `{}`", item.key));
310            }
311        }
312        let prefixed = |item: &NavItem| {
313            let mut item = item.clone();
314            item.path = item.path.map(|p| format!("{admin_path}{p}"));
315            item
316        };
317        let mut top: Vec<NavNode> = items
318            .iter()
319            .filter(|i| i.under.is_none())
320            .map(|i| NavNode::leaf(prefixed(i)))
321            .collect();
322        for item in items.iter().filter(|i| i.under.is_some()) {
323            let under = item.under.as_deref().unwrap_or_default();
324            let parent = top
325                .iter_mut()
326                .find(|n| n.item.key == under)
327                .ok_or_else(|| {
328                    format!(
329                        "menu item `{}` sits under `{under}`, which is no top-level item",
330                        item.key
331                    )
332                })?;
333            parent.children.push(NavNode::leaf(prefixed(item)));
334        }
335        let overrides = |key: &str| Overrides {
336            deployment: order.get(key).map(Vec::as_slice),
337            operator: None,
338        };
339        let top = arrange(&top_hierarchy(), top, overrides(TOP_HIERARCHY))
340            .map_err(|e| format!("menu: {e}"))?;
341        let mut arranged = Vec::with_capacity(top.len());
342        for mut node in top {
343            let key = format!("{TOP_HIERARCHY}.{}", node.item.key);
344            let children = std::mem::take(&mut node.children);
345            node.children = arrange(&Hierarchy::new(&key), children, overrides(&key))
346                .map_err(|e| format!("menu, under `{}`: {e}", node.item.key))?;
347            arranged.push(node);
348        }
349        Ok(Self { top: arranged })
350    }
351
352    /// The tree as one operator sees it: entries they may not open are gone,
353    /// and a section with nothing left beneath it and no page of its own goes
354    /// too.
355    pub(crate) fn visible(&self, allows: &dyn Fn(&str) -> bool) -> Vec<NavNode> {
356        let allowed = |item: &NavItem| item.permission.as_deref().is_none_or(allows);
357        self.top
358            .iter()
359            .filter_map(|node| {
360                let children: Vec<NavNode> = node
361                    .children
362                    .iter()
363                    .filter(|c| allowed(&c.item))
364                    .cloned()
365                    .collect();
366                let own = node.item.path.is_some() && allowed(&node.item);
367                if !own && children.is_empty() {
368                    return None;
369                }
370                if node.item.path.is_some() && !allowed(&node.item) {
371                    return None;
372                }
373                Some(NavNode {
374                    item: node.item.clone(),
375                    children,
376                    position: node.position.clone(),
377                })
378            })
379            .collect()
380    }
381}
382
383/// The preference key holding an operator's own order for the top bar.
384pub(crate) const ORDER_PREFERENCE: &str = "menu.order";
385
386/// The preference key holding an operator's own moves between the top bar
387/// and the settings index: a JSON object of entry key to `nav` or `settings`.
388pub(crate) const MOVES_PREFERENCE: &str = "menu.moves";
389
390/// The preference key holding an operator's own order for a hierarchy: the
391/// top bar keeps its first key, every other hierarchy is named.
392pub(crate) fn order_preference(hierarchy: &str) -> String {
393    if hierarchy == TOP_HIERARCHY {
394        ORDER_PREFERENCE.to_string()
395    } else {
396        format!("{ORDER_PREFERENCE}.{hierarchy}")
397    }
398}
399
400/// The hierarchies an operator may rearrange and move entries between.
401pub(crate) const OPERATOR_HIERARCHIES: [&str; 2] = [TOP_HIERARCHY, crate::settings::HIERARCHY];
402
403/// An operator's moves as stored (`{"backend.access_log": "nav"}`), as the
404/// engine's moves: `nav` is the top bar, `settings` the settings index.
405pub(crate) fn operator_moves(
406    stored: &str,
407) -> std::collections::BTreeMap<String, laterite_core::arrange::Move> {
408    let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(stored)
409    else {
410        return Default::default();
411    };
412    map.into_iter()
413        .filter_map(|(key, place)| {
414            let hierarchy = match place.as_str()? {
415                "nav" => TOP_HIERARCHY,
416                "settings" => crate::settings::HIERARCHY,
417                _ => return None,
418            };
419            let mut m = laterite_core::arrange::Move::default();
420            m.hierarchy = hierarchy.to_string();
421            Some((key, m))
422        })
423        .collect()
424}
425
426/// Puts the top-level nodes in the operator's own order: the keys in `order`
427/// (comma-separated) first, as listed, then the rest as they were. Dashboard
428/// stays first and Settings last whatever the order says, and a key that is
429/// no tab is ignored, so a stale preference never hides anything.
430pub(crate) fn apply_order(nodes: &mut Vec<NavNode>, order: &str) {
431    let wanted = laterite_core::arrange::parse_sequence(order);
432    if wanted.is_empty() {
433        return;
434    }
435    let taken = std::mem::take(nodes);
436    *nodes = laterite_core::arrange::arrange(
437        &top_hierarchy(),
438        taken,
439        laterite_core::arrange::Overrides {
440            deployment: None,
441            operator: Some(&wanted),
442        },
443    )
444    .unwrap_or_default();
445}
446
447/// The path a top-level node's tab leads to: its own, or its first entry's.
448pub(crate) fn tab_path(node: &NavNode) -> Option<&str> {
449    node.item
450        .path
451        .as_deref()
452        .or_else(|| node.children.first().and_then(|c| c.item.path.as_deref()))
453}
454
455/// Whether `path` is on `target` or beneath it.
456fn within(path: &str, target: &str, admin_path: &str) -> bool {
457    path == target || (target != admin_path && path.starts_with(&format!("{target}/")))
458}
459
460/// The key of the top-level node `path` falls under, by the longest match
461/// over its own path and its entries' paths.
462pub(crate) fn active_top<'a>(
463    visible: &'a [NavNode],
464    admin_path: &str,
465    path: &str,
466) -> Option<&'a NavNode> {
467    let mut best: Option<(&NavNode, usize)> = None;
468    for node in visible {
469        let candidates = node
470            .item
471            .path
472            .iter()
473            .chain(node.children.iter().filter_map(|c| c.item.path.as_ref()));
474        for target in candidates {
475            if within(path, target, admin_path) && best.is_none_or(|(_, len)| target.len() > len) {
476                best = Some((node, target.len()));
477            }
478        }
479    }
480    best.map(|(node, _)| node)
481}
482
483/// The context sidebar for a section: its entries as one group, the one
484/// `path` falls under marked active.
485pub(crate) fn section_sidebar(
486    node: &NavNode,
487    admin_path: &str,
488    path: &str,
489    tr: &laterite_core::Translator,
490    icons: crate::icons::Icons<'_>,
491) -> Vec<CategoryView> {
492    if node.children.is_empty() {
493        return Vec::new();
494    }
495    let active = node
496        .children
497        .iter()
498        .filter(|c| {
499            c.item
500                .path
501                .as_deref()
502                .is_some_and(|p| within(path, p, admin_path))
503        })
504        .max_by_key(|c| c.item.path.as_deref().map(str::len).unwrap_or(0))
505        .map(|c| c.item.key.clone());
506    vec![CategoryView::new(
507        tr.t(&node.item.label),
508        node.children
509            .iter()
510            .map(|c| ItemView {
511                key: c.item.key.clone(),
512                label: tr.t(&c.item.label),
513                description: String::new(),
514                path: c.item.path.clone().unwrap_or_default(),
515                icon: icons.render(c.item.icon.as_deref()),
516                active: active.as_deref() == Some(c.item.key.as_str()),
517            })
518            .collect(),
519    )]
520}
521
522/// Applies a deployment's `[backend.menu]` moves. A menu entry named in a
523/// move to `backend.settings` becomes a linked settings item; a settings item
524/// moved to `backend.nav` (or `backend.nav.<section>`) becomes a menu entry;
525/// a move within a hierarchy changes the band, the order or the section. An
526/// entry keeps its label, icon, permission and target wherever it renders. A
527/// key nobody declared, or a section (which has no page), is logged and left.
528pub(crate) fn apply_moves(
529    items: &mut Vec<NavItem>,
530    settings: &mut Vec<crate::settings::SettingsItem>,
531    moves: &std::collections::BTreeMap<String, laterite_core::arrange::Move>,
532) {
533    use laterite_core::arrange::slot;
534    for (key, spec) in moves {
535        let to_settings = spec.hierarchy == crate::settings::HIERARCHY;
536        let to_nav = spec.hierarchy == TOP_HIERARCHY
537            || spec.hierarchy.starts_with(&format!("{TOP_HIERARCHY}."));
538        if !to_settings && !to_nav {
539            tracing::warn!(key = %key, hierarchy = %spec.hierarchy, "a menu move names a hierarchy nothing renders");
540            continue;
541        }
542        let section = spec
543            .hierarchy
544            .strip_prefix(&format!("{TOP_HIERARCHY}."))
545            .map(str::to_string);
546        if let Some(at) = items.iter().position(|i| &i.key == key) {
547            if to_nav {
548                let item = &mut items[at];
549                item.under = section;
550                if let Some(slot) = spec.slot {
551                    item.slot = slot;
552                }
553                if let Some(order) = spec.order {
554                    item.order = order;
555                }
556                continue;
557            }
558            let Some(path) = items[at].path.clone() else {
559                tracing::warn!(key = %key, "a section has no page of its own and cannot move into settings");
560                continue;
561            };
562            let item = items.remove(at);
563            let band = spec.slot.unwrap_or(item.slot);
564            let mut moved = crate::settings::SettingsItem::new(
565                item.key.clone(),
566                item.label.clone(),
567                Vec::new(),
568            )
569            .category(laterite_core::Text::dynamic(
570                spec.category
571                    .clone()
572                    .unwrap_or_else(|| title(&slot::name(band))),
573            ))
574            .slot(band)
575            .order(spec.order.unwrap_or(item.order))
576            .link(path);
577            if let Some(icon) = item.icon.clone() {
578                moved = moved.icon(icon);
579            }
580            if let Some(permission) = item.permission.clone() {
581                moved = moved.permission(permission);
582            }
583            settings.push(moved);
584            continue;
585        }
586        if let Some(at) = settings.iter().position(|i| &i.code == key) {
587            if to_settings {
588                let item = &mut settings[at];
589                if let Some(slot) = spec.slot {
590                    item.slot = slot;
591                }
592                if let Some(order) = spec.order {
593                    item.order = order;
594                }
595                if let Some(category) = &spec.category {
596                    item.category = laterite_core::Text::dynamic(category.clone());
597                }
598                continue;
599            }
600            let item = settings.remove(at);
601            let path = item.link.clone().unwrap_or_else(|| item.route());
602            let mut moved = NavItem::new(item.code.clone(), item.label.clone(), path)
603                .slot(spec.slot.unwrap_or(item.slot))
604                .order(spec.order.unwrap_or(item.order));
605            moved.icon = item.icon.clone();
606            moved.permission = item.permission.clone();
607            moved.under = section;
608            items.push(moved);
609            continue;
610        }
611        tracing::warn!(key = %key, "a menu move names an entry nothing declared");
612    }
613}
614
615/// `tools` as a heading: `Tools`.
616fn title(name: &str) -> String {
617    let mut chars = name.chars();
618    match chars.next() {
619        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
620        None => String::new(),
621    }
622}
623
624impl NavTree {
625    /// One boot log line per hierarchy, so an author sees where an entry
626    /// landed without opening the panel.
627    pub(crate) fn log_arrangement(&self) {
628        let top: Vec<String> = self
629            .top
630            .iter()
631            .map(|n| {
632                format!(
633                    "{} ({})",
634                    n.item.key,
635                    laterite_core::arrange::slot::name(n.item.slot)
636                )
637            })
638            .collect();
639        tracing::info!(hierarchy = TOP_HIERARCHY, arrangement = %top.join(" > "));
640        for node in &self.top {
641            if node.children.is_empty() {
642                continue;
643            }
644            let keys: Vec<&str> = node.children.iter().map(|c| c.item.key.as_str()).collect();
645            tracing::info!(hierarchy = %format!("{TOP_HIERARCHY}.{}", node.item.key), arrangement = %keys.join(" > "));
646        }
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn items() -> Vec<NavItem> {
655        vec![
656            NavItem::new("dashboard", "Dashboard", "").order(i32::MIN),
657            NavItem::section("content", "Content").order(10),
658            NavItem::new("acme.posts", "Posts", "/posts")
659                .under("content")
660                .permission("acme.posts")
661                .order(2),
662            NavItem::new("acme.pages", "Pages", "/pages")
663                .under("content")
664                .permission("acme.pages")
665                .order(1),
666            NavItem::new("acme.shop", "Shop", "/shop")
667                .permission("acme.shop")
668                .order(20),
669            NavItem::new("settings", "Settings", "/settings").order(i32::MAX),
670        ]
671    }
672
673    #[test]
674    fn a_tree_sorts_and_prefixes() {
675        let tree = NavTree::build(items(), "/admin").unwrap();
676        let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
677        assert_eq!(keys, ["dashboard", "content", "acme.shop", "settings"]);
678        let content = &tree.top[1];
679        let entries: Vec<&str> = content
680            .children
681            .iter()
682            .map(|n| n.item.key.as_str())
683            .collect();
684        assert_eq!(
685            entries,
686            ["acme.pages", "acme.posts"],
687            "by order, then label"
688        );
689        assert_eq!(
690            content.children[0].item.path.as_deref(),
691            Some("/admin/pages")
692        );
693        assert_eq!(tab_path(content), Some("/admin/pages"));
694    }
695
696    #[test]
697    fn an_anchor_holds_whatever_the_orders_say() {
698        let mut items = items();
699        items.push(
700            NavItem::new("acme.help", "Help", "/help")
701                .order(0)
702                .after("acme.shop"),
703        );
704        items.push(
705            NavItem::new("acme.tags", "Tags", "/tags")
706                .under("content")
707                .order(99)
708                .before("acme.pages"),
709        );
710        let tree = NavTree::build(items, "/admin").unwrap();
711        let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
712        assert_eq!(
713            keys,
714            ["dashboard", "content", "acme.shop", "acme.help", "settings"]
715        );
716        let entries: Vec<&str> = tree.top[1]
717            .children
718            .iter()
719            .map(|n| n.item.key.as_str())
720            .collect();
721        assert_eq!(entries, ["acme.tags", "acme.pages", "acme.posts"]);
722
723        let mut lost = self::items();
724        lost.push(NavItem::new("acme.help", "Help", "/help").after("acme.pages"));
725        assert!(
726            NavTree::build(lost, "/admin").is_err(),
727            "an anchor must be a sibling"
728        );
729    }
730
731    #[test]
732    fn an_operators_order_moves_tabs_but_never_the_ends() {
733        let tree = NavTree::build(items(), "/admin").unwrap();
734        let mut visible = tree.visible(&|_| true);
735        apply_order(&mut visible, "settings,acme.shop,gone,dashboard");
736        let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
737        assert_eq!(keys, ["dashboard", "acme.shop", "content", "settings"]);
738        apply_order(&mut visible, "");
739        let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
740        assert_eq!(
741            keys,
742            ["dashboard", "acme.shop", "content", "settings"],
743            "blank changes nothing"
744        );
745    }
746
747    #[test]
748    fn a_duplicate_key_and_an_unknown_section_are_refused() {
749        let mut dup = items();
750        dup.push(NavItem::new("acme.shop", "Again", "/again"));
751        assert!(NavTree::build(dup, "/admin").is_err());
752        let mut orphan = items();
753        orphan.push(NavItem::new("acme.lost", "Lost", "/lost").under("nowhere"));
754        assert!(NavTree::build(orphan, "/admin").is_err());
755    }
756
757    #[test]
758    fn an_operator_sees_only_what_they_may_open() {
759        let tree = NavTree::build(items(), "/admin").unwrap();
760        let visible = tree.visible(&|code| code == "acme.posts");
761        let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
762        assert_eq!(keys, ["dashboard", "content", "settings"], "no shop");
763        assert_eq!(visible[1].children.len(), 1, "only posts under content");
764        assert_eq!(tab_path(&visible[1]), Some("/admin/posts"));
765        let none = tree.visible(&|_| false);
766        let keys: Vec<&str> = none.iter().map(|n| n.item.key.as_str()).collect();
767        assert_eq!(keys, ["dashboard", "settings"], "an empty section goes");
768    }
769
770    #[test]
771    fn the_active_tab_is_the_longest_match() {
772        let tree = NavTree::build(items(), "/admin").unwrap();
773        let visible = tree.visible(&|_| true);
774        let at = |path: &str| active_top(&visible, "/admin", path).map(|n| n.item.key.clone());
775        assert_eq!(at("/admin/posts/3/edit").as_deref(), Some("content"));
776        assert_eq!(at("/admin/shop").as_deref(), Some("acme.shop"));
777        assert_eq!(at("/admin/settings/users").as_deref(), Some("settings"));
778        assert_eq!(at("/admin").as_deref(), Some("dashboard"));
779        assert_eq!(at("/admin/elsewhere"), None);
780    }
781}