use std::collections::HashSet;
use std::path::PathBuf;
use crate::catalog;
use crate::error::ItemKind;
use crate::tui::app::FlatNode;
use crate::tui::data::Snapshot;
#[derive(Debug, Clone)]
#[allow(dead_code)] pub enum TreeNode {
InstalledGroup,
AvailableGroup,
Source(SourceInfo),
KindBucket { source: String, kind: ItemKind },
InstalledItem(InstalledInfo),
AvailableItem(AvailableInfo),
SuggestedSource(SuggestedSourceInfo),
UnmanagedGroup,
UnmanagedItem(UnmanagedInfo),
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SourceInfo {
pub name: String,
pub installed: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct InstalledInfo {
pub key: String,
pub name: String,
pub source: String,
pub kind: ItemKind,
pub commit: String,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AvailableInfo {
pub key: String,
pub name: String,
pub source: String,
pub kind: ItemKind,
pub description: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct UnmanagedInfo {
pub key: String,
pub name: String,
pub kind: ItemKind,
pub paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SuggestedSourceInfo {
pub spec: String,
pub name: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct Node {
pub id: String,
pub label: String,
pub node: TreeNode,
pub children: Vec<Node>,
}
pub fn build_tree(
snap: &Snapshot,
search: &str,
kind_filter: Option<ItemKind>,
source_filter: Option<&str>,
installed_collapsed: bool,
available_collapsed: bool,
) -> Vec<Node> {
let mut roots = Vec::new();
let installed_node = build_installed_group(snap, search, kind_filter, source_filter);
let installed_count = count_items(&installed_node.children);
let installed_label = if installed_collapsed {
"Installed (collapsed)".to_string()
} else {
format!("Installed ({installed_count})")
};
roots.push(Node {
id: "group:installed".to_string(),
label: installed_label,
children: if installed_collapsed {
vec![]
} else {
installed_node.children
},
node: TreeNode::InstalledGroup,
});
let available_node = build_available_group(snap, search, kind_filter, source_filter);
let available_count = count_items(&available_node.children);
let available_label = if available_collapsed {
"Available (collapsed)".to_string()
} else {
format!("Available ({available_count})")
};
roots.push(Node {
id: "group:available".to_string(),
label: available_label,
children: if available_collapsed {
vec![]
} else {
available_node.children
},
node: TreeNode::AvailableGroup,
});
let unmanaged_children = build_unmanaged_children(snap, search, kind_filter, source_filter);
if !unmanaged_children.is_empty() {
roots.push(Node {
id: "group:unmanaged".to_string(),
label: format!("Unmanaged ({})", unmanaged_children.len()),
node: TreeNode::UnmanagedGroup,
children: unmanaged_children,
});
}
roots
}
fn count_items(children: &[Node]) -> usize {
let mut n = 0;
for c in children {
match &c.node {
TreeNode::InstalledItem(_)
| TreeNode::AvailableItem(_)
| TreeNode::UnmanagedItem(_) => n += 1,
_ => n += count_items(&c.children),
}
}
n
}
fn build_installed_group(
snap: &Snapshot,
search: &str,
kind_filter: Option<ItemKind>,
source_filter: Option<&str>,
) -> Node {
let mut by_source: std::collections::BTreeMap<
String,
Vec<&crate::tui::data::SnapshotInstalled>,
> = std::collections::BTreeMap::new();
for item in &snap.installed {
if kind_filter.is_some_and(|kf| item.kind != kf) {
continue;
}
if source_filter.is_some_and(|sf| !crate::resolve::source_matches(&item.source, sf)) {
continue;
}
if !item_matches_search_installed(item, search) {
continue;
}
by_source.entry(item.source.clone()).or_default().push(item);
}
let mut source_nodes = Vec::new();
for (src_name, items) in &by_source {
let mut kind_map: std::collections::BTreeMap<
ItemKind,
Vec<&&crate::tui::data::SnapshotInstalled>,
> = std::collections::BTreeMap::new();
for item in items {
kind_map.entry(item.kind).or_default().push(item);
}
let mut kind_nodes = Vec::new();
for (kind, kind_items) in &kind_map {
let item_nodes: Vec<Node> = kind_items
.iter()
.map(|it| Node {
id: format!("installed:{}:{}", src_name, it.key),
label: format!(
"{} [{}]{}",
it.name,
short_commit(&it.commit),
it.description
.as_deref()
.map(|d| format!(" - {}", truncate(d, 50)))
.unwrap_or_default()
),
node: TreeNode::InstalledItem(InstalledInfo {
key: it.key.clone(),
name: it.name.clone(),
source: it.source.clone(),
kind: it.kind,
commit: it.commit.clone(),
description: it.description.clone(),
}),
children: vec![],
})
.collect();
kind_nodes.push(Node {
id: format!("installed-kind:{}:{}", src_name, kind.as_str()),
label: format!("{} ({})", kind.as_str(), item_nodes.len()),
node: TreeNode::KindBucket {
source: src_name.clone(),
kind: *kind,
},
children: item_nodes,
});
}
source_nodes.push(Node {
id: format!("installed-source:{}", src_name),
label: src_name.clone(),
node: TreeNode::Source(SourceInfo {
name: src_name.clone(),
installed: true,
}),
children: kind_nodes,
});
}
Node {
id: "group:installed".to_string(),
label: "Installed".to_string(),
node: TreeNode::InstalledGroup,
children: source_nodes,
}
}
fn build_available_group(
snap: &Snapshot,
search: &str,
kind_filter: Option<ItemKind>,
source_filter: Option<&str>,
) -> Node {
let installed_keys: HashSet<String> = snap.installed.iter().map(|i| i.key.clone()).collect();
let mut by_source: std::collections::BTreeMap<
String,
Vec<&crate::tui::data::SnapshotAvailable>,
> = std::collections::BTreeMap::new();
for item in &snap.available {
if installed_keys.contains(&item.key) {
continue;
}
if kind_filter.is_some_and(|kf| item.kind != kf) {
continue;
}
if source_filter.is_some_and(|sf| !crate::resolve::source_matches(&item.source, sf)) {
continue;
}
if !item_matches_search_available(item, search) {
continue;
}
by_source.entry(item.source.clone()).or_default().push(item);
}
let mut source_nodes = Vec::new();
for (src_name, items) in &by_source {
let mut kind_map: std::collections::BTreeMap<
ItemKind,
Vec<&&crate::tui::data::SnapshotAvailable>,
> = std::collections::BTreeMap::new();
for item in items {
kind_map.entry(item.kind).or_default().push(item);
}
let mut kind_nodes = Vec::new();
for (kind, kind_items) in &kind_map {
let item_nodes: Vec<Node> = kind_items
.iter()
.map(|it| Node {
id: format!("available:{}:{}", src_name, it.key),
label: format!(
"{}{}",
it.name,
it.description
.as_deref()
.map(|d| format!(" - {}", truncate(d, 50)))
.unwrap_or_default()
),
node: TreeNode::AvailableItem(AvailableInfo {
key: it.key.clone(),
name: it.name.clone(),
source: it.source.clone(),
kind: it.kind,
description: it.description.clone(),
path: it.path.clone(),
}),
children: vec![],
})
.collect();
kind_nodes.push(Node {
id: format!("available-kind:{}:{}", src_name, kind.as_str()),
label: format!("{} ({})", kind.as_str(), item_nodes.len()),
node: TreeNode::KindBucket {
source: src_name.clone(),
kind: *kind,
},
children: item_nodes,
});
}
source_nodes.push(Node {
id: format!("available-source:{}", src_name),
label: src_name.clone(),
node: TreeNode::Source(SourceInfo {
name: src_name.clone(),
installed: false,
}),
children: kind_nodes,
});
}
for sug in &snap.suggestions {
if !search.is_empty() && !sug.name.to_lowercase().contains(&search.to_lowercase()) {
continue;
}
source_nodes.push(Node {
id: format!("suggested:{}", sug.url),
label: format!("{} [suggested]", sug.name),
node: TreeNode::SuggestedSource(SuggestedSourceInfo {
spec: sug.spec.clone(),
name: sug.name.clone(),
url: sug.url.clone(),
}),
children: vec![],
});
}
Node {
id: "group:available".to_string(),
label: "Available".to_string(),
node: TreeNode::AvailableGroup,
children: source_nodes,
}
}
fn build_unmanaged_children(
snap: &Snapshot,
search: &str,
kind_filter: Option<ItemKind>,
source_filter: Option<&str>,
) -> Vec<Node> {
if source_filter.is_some() {
return Vec::new();
}
let needle = search.to_lowercase();
snap.unmanaged
.iter()
.filter(|it| kind_filter.is_none_or(|kf| it.kind == kf))
.filter(|it| needle.is_empty() || it.name.to_lowercase().contains(&needle))
.map(|it| Node {
id: format!("unmanaged:{}", it.key),
label: format!("{} [{}] (unmanaged)", it.name, it.kind.as_str()),
node: TreeNode::UnmanagedItem(UnmanagedInfo {
key: it.key.clone(),
name: it.name.clone(),
kind: it.kind,
paths: it.paths.clone(),
}),
children: vec![],
})
.collect()
}
fn item_matches_search_installed(item: &crate::tui::data::SnapshotInstalled, search: &str) -> bool {
if search.is_empty() {
return true;
}
let fake = catalog::CatalogItem {
kind: item.kind,
name: item.name.clone(),
source: item.source.clone(),
prefix: None,
path: std::path::PathBuf::new(),
description: item.description.clone(),
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
catalog::matches_query(&fake, search)
}
fn item_matches_search_available(item: &crate::tui::data::SnapshotAvailable, search: &str) -> bool {
if search.is_empty() {
return true;
}
let fake = catalog::CatalogItem {
kind: item.kind,
name: item.name.clone(),
source: item.source.clone(),
prefix: None,
path: item.path.clone(),
description: item.description.clone(),
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
catalog::matches_query(&fake, search)
}
pub fn is_auto_expanded(node: &TreeNode) -> bool {
matches!(
node,
TreeNode::InstalledGroup
| TreeNode::AvailableGroup
| TreeNode::UnmanagedGroup
| TreeNode::Source(_)
| TreeNode::KindBucket { .. }
)
}
pub fn flatten_tree(
nodes: &[Node],
expanded: &HashSet<String>,
collapsed: &HashSet<String>,
) -> Vec<FlatNode> {
let mut out = Vec::new();
for node in nodes {
flatten_node(node, 0, expanded, collapsed, &mut out);
}
out
}
fn flatten_node(
node: &Node,
depth: usize,
expanded: &HashSet<String>,
collapsed: &HashSet<String>,
out: &mut Vec<FlatNode>,
) {
let is_exp = if is_auto_expanded(&node.node) {
!collapsed.contains(&node.id)
} else {
expanded.contains(&node.id)
};
let expandable = !node.children.is_empty();
out.push(FlatNode {
id: node.id.clone(),
label: node.label.clone(),
depth,
expandable,
expanded: is_exp,
node: node.node.clone(),
});
if is_exp {
for child in &node.children {
flatten_node(child, depth + 1, expanded, collapsed, out);
}
}
}
fn short_commit(s: &str) -> String {
if s.is_empty() {
"-".to_string()
} else {
s.chars().take(8).collect()
}
}
fn truncate(s: &str, max: usize) -> &str {
if s.chars().count() <= max {
s
} else {
let idx = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
&s[..idx]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::tui::data::{Snapshot, SnapshotAvailable, SnapshotInstalled};
use std::collections::HashSet;
fn make_installed(key: &str, name: &str, source: &str, kind: ItemKind) -> SnapshotInstalled {
SnapshotInstalled {
key: key.to_string(),
name: name.to_string(),
source: source.to_string(),
kind,
commit: "abc12345".to_string(),
description: Some(format!("{name} description")),
}
}
fn make_available(key: &str, name: &str, source: &str, kind: ItemKind) -> SnapshotAvailable {
SnapshotAvailable {
key: key.to_string(),
name: name.to_string(),
source: source.to_string(),
kind,
description: Some(format!("{name} description")),
path: std::path::PathBuf::from(format!("/fake/{name}")),
}
}
fn snap_with(installed: Vec<SnapshotInstalled>, available: Vec<SnapshotAvailable>) -> Snapshot {
Snapshot {
generation: 1,
installed,
available,
unmanaged: vec![],
source_names: vec!["src/a".to_string()],
suggestions: vec![],
lobes: vec![],
}
}
fn make_unmanaged(name: &str, kind: ItemKind) -> crate::tui::data::SnapshotUnmanaged {
crate::tui::data::SnapshotUnmanaged {
key: format!("{}:{}", kind.as_str(), name),
name: name.to_string(),
kind,
paths: vec![std::path::PathBuf::from(format!("/lobe/{name}"))],
}
}
#[test]
fn tree_has_installed_and_available_groups() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![make_available("agent:dev", "dev", "src/a", ItemKind::Agent)],
);
let nodes = build_tree(&snap, "", None, None, false, false);
assert_eq!(nodes.len(), 2);
assert!(matches!(nodes[0].node, TreeNode::InstalledGroup));
assert!(matches!(nodes[1].node, TreeNode::AvailableGroup));
}
#[test]
fn installed_group_contains_installed_items() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let has_review = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(i) if i.name == "review"));
assert!(
has_review,
"installed item should appear in flat tree: {:?}",
flat.iter().map(|n| &n.label).collect::<Vec<_>>()
);
}
#[test]
fn available_group_excludes_installed_items() {
let installed = make_installed("skill:review", "review", "src/a", ItemKind::Skill);
let also_avail = make_available("skill:review", "review", "src/a", ItemKind::Skill);
let snap = snap_with(vec![installed], vec![also_avail]);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let avail_review = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "review"));
assert!(
!avail_review,
"installed item must not appear in Available tree"
);
}
#[test]
fn search_filters_available_items() {
let snap = snap_with(
vec![],
vec![
make_available("agent:dev", "dev", "src/a", ItemKind::Agent),
make_available("agent:plan", "plan", "src/a", ItemKind::Agent),
],
);
let nodes = build_tree(&snap, "dev", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let has_dev = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "dev"));
let has_plan = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "plan"));
assert!(has_dev, "dev should match search 'dev'");
assert!(!has_plan, "plan should not match search 'dev'");
}
#[test]
fn search_is_case_insensitive() {
let snap = snap_with(
vec![],
vec![make_available(
"skill:Review",
"Review",
"src/a",
ItemKind::Skill,
)],
);
let nodes = build_tree(&snap, "review", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let found = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "Review"));
assert!(found, "search should be case-insensitive");
}
#[test]
fn search_matches_description() {
let mut item = make_available("skill:x", "x", "src/a", ItemKind::Skill);
item.description = Some("automated code formatter".to_string());
let snap = snap_with(vec![], vec![item]);
let nodes = build_tree(&snap, "formatter", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let found = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "x"));
assert!(found, "search should match description text");
}
#[test]
fn kind_filter_narrows_available() {
let snap = snap_with(
vec![],
vec![
make_available("skill:s", "s", "src/a", ItemKind::Skill),
make_available("agent:a", "a", "src/a", ItemKind::Agent),
],
);
let nodes = build_tree(&snap, "", Some(ItemKind::Skill), None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let has_skill = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "s"));
let has_agent = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "a"));
assert!(has_skill, "skill should survive kind=Skill filter");
assert!(!has_agent, "agent should be filtered out by kind=Skill");
}
#[test]
fn flatten_shows_items_by_default() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let group_visible = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledGroup));
assert!(group_visible, "group header should always be visible");
let item_visible = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(
item_visible,
"items should be visible by default (auto-expand): {:?}",
flat.iter().map(|n| &n.label).collect::<Vec<_>>()
);
}
#[test]
fn search_and_kind_filter_compose() {
let snap = snap_with(
vec![],
vec![
make_available("skill:review", "review", "src/a", ItemKind::Skill),
make_available("skill:build", "build", "src/a", ItemKind::Skill),
make_available("agent:render", "render", "src/a", ItemKind::Agent),
],
);
let nodes = build_tree(&snap, "re", Some(ItemKind::Skill), None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let names: Vec<&str> = flat
.iter()
.filter_map(|n| match &n.node {
TreeNode::AvailableItem(i) => Some(i.name.as_str()),
_ => None,
})
.collect();
assert!(
names.contains(&"review"),
"review matches both axes: {names:?}"
);
assert!(
!names.contains(&"build"),
"build fails the search axis: {names:?}"
);
assert!(
!names.contains(&"render"),
"render fails the kind axis: {names:?}"
);
}
#[test]
fn search_and_source_filter_compose() {
let snap = snap_with(
vec![],
vec![
make_available("skill:review", "review", "a/agents", ItemKind::Skill),
make_available("skill:review", "review", "b/agents", ItemKind::Skill),
make_available("skill:build", "build", "a/agents", ItemKind::Skill),
],
);
let nodes = build_tree(&snap, "review", None, Some("a/agents"), false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let from_a = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::Source(s) if s.name == "a/agents"));
let from_b = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::Source(s) if s.name == "b/agents"));
assert!(
from_a,
"a/agents source should remain after composed filters"
);
assert!(
!from_b,
"b/agents source must be excluded by the source filter"
);
let has_build = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "build"));
assert!(!has_build, "build must be excluded by the search filter");
}
#[test]
fn clearing_search_restores_full_tree() {
let snap = snap_with(
vec![],
vec![
make_available("skill:review", "review", "src/a", ItemKind::Skill),
make_available("agent:dev", "dev", "src/a", ItemKind::Agent),
make_available("rule:style", "style", "src/a", ItemKind::Rule),
],
);
let filtered = flatten_tree(
&build_tree(&snap, "review", None, None, false, false),
&HashSet::new(),
&HashSet::new(),
);
let restored = flatten_tree(
&build_tree(&snap, "", None, None, false, false),
&HashSet::new(),
&HashSet::new(),
);
let filtered_items = filtered
.iter()
.filter(|n| matches!(&n.node, TreeNode::AvailableItem(_)))
.count();
let restored_items = restored
.iter()
.filter(|n| matches!(&n.node, TreeNode::AvailableItem(_)))
.count();
assert_eq!(
filtered_items, 1,
"search 'review' should match exactly one item"
);
assert_eq!(
restored_items, 3,
"clearing the search restores all three items"
);
for want in ["dev", "style", "review"] {
assert!(
restored
.iter()
.any(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == want)),
"{want} should reappear after clearing search"
);
}
}
#[test]
fn search_surfaces_description_only_match_with_filter_active() {
let mut item = make_available("skill:fmt", "fmt", "src/a", ItemKind::Skill);
item.description = Some("automated code formatter".to_string());
let other = make_available("agent:fmt", "fmt", "src/a", ItemKind::Agent);
let snap = snap_with(vec![], vec![item, other]);
let nodes = build_tree(
&snap,
"formatter",
Some(ItemKind::Skill),
None,
false,
false,
);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let has_skill = flat.iter().any(|n| {
matches!(&n.node, TreeNode::AvailableItem(i)
if i.name == "fmt" && i.kind == ItemKind::Skill)
});
let has_agent = flat.iter().any(|n| {
matches!(&n.node, TreeNode::AvailableItem(i)
if i.kind == ItemKind::Agent)
});
assert!(
has_skill,
"description-only match must surface the skill: {:?}",
flat.iter().map(|n| &n.label).collect::<Vec<_>>()
);
assert!(
!has_agent,
"the agent (wrong kind, no desc match) must be filtered out"
);
}
#[test]
fn two_uninstalled_sources_same_name_both_appear() {
let from_a = make_available("skill:review", "review", "a/agents", ItemKind::Skill);
let from_b = make_available("skill:review", "review", "b/agents", ItemKind::Skill);
let snap = snap_with(vec![], vec![from_a, from_b]); let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let review_count = flat
.iter()
.filter(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "review"))
.count();
assert_eq!(
review_count,
2,
"both uninstalled same-name items should appear (one per source): {:?}",
flat.iter().map(|n| &n.label).collect::<Vec<_>>()
);
let source_a = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::Source(s) if s.name == "a/agents"));
let source_b = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::Source(s) if s.name == "b/agents"));
assert!(source_a && source_b, "both source nodes should be present");
}
#[test]
fn suggested_source_with_melded_url_excluded_at_data_layer() {
let snap = snap_with(vec![], vec![]); let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let any_suggested = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::SuggestedSource(_)));
assert!(!any_suggested, "no suggestions -> no SuggestedSource nodes");
}
#[test]
fn installed_search_matches_description_not_just_name() {
let mut item = make_installed("skill:fmt", "fmt", "src/a", ItemKind::Skill);
item.description = Some("automated code formatter".to_string());
let other = make_installed("skill:lint", "lint", "src/a", ItemKind::Skill);
let snap = snap_with(vec![item, other], vec![]);
let nodes = build_tree(&snap, "formatter", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let has_fmt = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(i) if i.name == "fmt"));
let has_lint = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(i) if i.name == "lint"));
assert!(
has_fmt,
"installed item should be matched by description: {:?}",
flat.iter().map(|n| &n.label).collect::<Vec<_>>()
);
assert!(
!has_lint,
"installed item with no name/desc match must be filtered out"
);
}
#[test]
fn available_dedup_across_two_sources() {
let installed = make_installed("skill:review", "review", "src/a", ItemKind::Skill);
let also_from_b = make_available("skill:review", "review", "src/b", ItemKind::Skill);
let snap = snap_with(vec![installed], vec![also_from_b]);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let avail_count = flat
.iter()
.filter(|n| matches!(&n.node, TreeNode::AvailableItem(i) if i.name == "review"))
.count();
assert_eq!(
avail_count, 0,
"same key in available should be deduped vs installed"
);
}
#[test]
fn source_id_in_collapsed_hides_its_children() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat_default = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let source_id = flat_default
.iter()
.find(|n| matches!(&n.node, TreeNode::Source(_)))
.map(|n| n.id.clone())
.expect("a Source node must be present");
let has_item = flat_default
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(
has_item,
"item should be visible when source is not collapsed"
);
let mut collapsed = HashSet::new();
collapsed.insert(source_id.clone());
let flat_collapsed = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let has_item_after = flat_collapsed
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
let has_bucket_after = flat_collapsed
.iter()
.any(|n| matches!(&n.node, TreeNode::KindBucket { .. }));
assert!(
!has_item_after,
"item must be absent when source is in the collapsed set"
);
assert!(
!has_bucket_after,
"KindBucket must also be absent when source is in the collapsed set"
);
let source_visible = flat_collapsed.iter().any(|n| n.id == source_id);
assert!(source_visible, "the Source node itself must remain visible");
}
#[test]
fn kind_bucket_id_in_collapsed_hides_its_items() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat_default = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let bucket_id = flat_default
.iter()
.find(|n| matches!(&n.node, TreeNode::KindBucket { .. }))
.map(|n| n.id.clone())
.expect("a KindBucket node must be present");
let has_item = flat_default
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(
has_item,
"item should be visible when bucket is not collapsed"
);
let mut collapsed = HashSet::new();
collapsed.insert(bucket_id.clone());
let flat_collapsed = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let has_item_after = flat_collapsed
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(
!has_item_after,
"item must be absent when KindBucket is in the collapsed set"
);
let bucket_visible = flat_collapsed.iter().any(|n| n.id == bucket_id);
assert!(
bucket_visible,
"the KindBucket node itself must stay visible"
);
}
#[test]
fn removing_source_from_collapsed_restores_children() {
let snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
let nodes = build_tree(&snap, "", None, None, false, false);
let flat_default = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let source_id = flat_default
.iter()
.find(|n| matches!(&n.node, TreeNode::Source(_)))
.map(|n| n.id.clone())
.expect("a Source node must be present");
let mut collapsed = HashSet::new();
collapsed.insert(source_id.clone());
let flat_collapsed = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let hidden = flat_collapsed
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(!hidden, "items hidden after collapse");
collapsed.remove(&source_id);
let flat_restored = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let restored = flat_restored
.iter()
.any(|n| matches!(&n.node, TreeNode::InstalledItem(_)));
assert!(
restored,
"items restored after removing source from collapsed set"
);
}
#[test]
fn normal_item_node_still_requires_expanded_membership() {
let parent = Node {
id: "item-parent".to_string(),
label: "parent".to_string(),
node: TreeNode::InstalledItem(InstalledInfo {
key: "skill:parent".to_string(),
name: "parent".to_string(),
source: "src/a".to_string(),
kind: ItemKind::Skill,
commit: "abc".to_string(),
description: None,
}),
children: vec![Node {
id: "item-child".to_string(),
label: "child".to_string(),
node: TreeNode::InstalledItem(InstalledInfo {
key: "skill:child".to_string(),
name: "child".to_string(),
source: "src/a".to_string(),
kind: ItemKind::Skill,
commit: "abc".to_string(),
description: None,
}),
children: vec![],
}],
};
let nodes = vec![parent];
let flat_empty = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let child_visible = flat_empty.iter().any(|n| n.id == "item-child");
assert!(
!child_visible,
"InstalledItem child must be hidden without expanded membership"
);
let mut collapsed = HashSet::new();
collapsed.insert("item-parent".to_string());
let flat_only_collapsed = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let child_visible2 = flat_only_collapsed.iter().any(|n| n.id == "item-child");
assert!(
!child_visible2,
"collapsed set must not expand non-auto nodes (they need `expanded` membership)"
);
let mut expanded = HashSet::new();
expanded.insert("item-parent".to_string());
let flat_expanded = flatten_tree(&nodes, &expanded, &HashSet::new());
let child_visible3 = flat_expanded.iter().any(|n| n.id == "item-child");
assert!(
child_visible3,
"InstalledItem child must appear when parent is in `expanded`"
);
}
fn snap_with_unmanaged(unmanaged: Vec<crate::tui::data::SnapshotUnmanaged>) -> Snapshot {
let mut snap = snap_with(vec![], vec![]);
snap.unmanaged = unmanaged;
snap
}
#[test]
fn unmanaged_group_appears_with_items() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("hand-written", ItemKind::Skill),
make_unmanaged("foreign", ItemKind::Agent),
]);
let nodes = build_tree(&snap, "", None, None, false, false);
let group = nodes
.iter()
.find(|n| matches!(n.node, TreeNode::UnmanagedGroup))
.expect("an unmanaged group node must be present");
assert_eq!(group.label, "Unmanaged (2)");
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let names: Vec<&str> = flat
.iter()
.filter_map(|n| match &n.node {
TreeNode::UnmanagedItem(i) => Some(i.name.as_str()),
_ => None,
})
.collect();
assert!(names.contains(&"hand-written") && names.contains(&"foreign"));
}
#[test]
fn no_unmanaged_group_when_none() {
let snap = snap_with_unmanaged(vec![]);
let nodes = build_tree(&snap, "", None, None, false, false);
assert!(
!nodes
.iter()
.any(|n| matches!(n.node, TreeNode::UnmanagedGroup)),
"no unmanaged items -> no unmanaged group"
);
}
#[test]
fn unmanaged_search_filters_by_name() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("review", ItemKind::Skill),
make_unmanaged("deploy", ItemKind::Skill),
]);
let nodes = build_tree(&snap, "rev", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let names: Vec<&str> = flat
.iter()
.filter_map(|n| match &n.node {
TreeNode::UnmanagedItem(i) => Some(i.name.as_str()),
_ => None,
})
.collect();
assert_eq!(names, vec!["review"], "search 'rev' keeps only review");
}
#[test]
fn unmanaged_kind_filter_applies() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("s", ItemKind::Skill),
make_unmanaged("a", ItemKind::Agent),
]);
let nodes = build_tree(&snap, "", Some(ItemKind::Skill), None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let kinds: Vec<ItemKind> = flat
.iter()
.filter_map(|n| match &n.node {
TreeNode::UnmanagedItem(i) => Some(i.kind),
_ => None,
})
.collect();
assert_eq!(kinds, vec![ItemKind::Skill], "kind=Skill drops the agent");
}
#[test]
fn unmanaged_source_filter_excludes_them() {
let snap = snap_with_unmanaged(vec![make_unmanaged("x", ItemKind::Skill)]);
let nodes = build_tree(&snap, "", None, Some("some/source"), false, false);
assert!(
!nodes
.iter()
.any(|n| matches!(n.node, TreeNode::UnmanagedGroup)),
"a source filter must exclude the unmanaged group"
);
}
#[test]
fn unmanaged_group_label_count_reflects_filtered_visible_items() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("a", ItemKind::Skill),
make_unmanaged("b", ItemKind::Agent),
make_unmanaged("c", ItemKind::Agent),
]);
let nodes = build_tree(&snap, "", Some(ItemKind::Skill), None, false, false);
let group = nodes
.iter()
.find(|n| matches!(n.node, TreeNode::UnmanagedGroup))
.expect("an unmanaged group node must be present");
assert_eq!(
group.label, "Unmanaged (1)",
"label count must reflect items surviving the kind filter"
);
let item_count = group
.children
.iter()
.filter(|n| matches!(&n.node, TreeNode::UnmanagedItem(_)))
.count();
assert_eq!(
item_count, 1,
"the visible item count must equal the label count"
);
}
#[test]
fn unmanaged_search_composes_with_kind_filter() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("review", ItemKind::Skill),
make_unmanaged("build", ItemKind::Skill),
make_unmanaged("render", ItemKind::Agent),
]);
let nodes = build_tree(&snap, "re", Some(ItemKind::Skill), None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let names: Vec<&str> = flat
.iter()
.filter_map(|n| match &n.node {
TreeNode::UnmanagedItem(i) => Some(i.name.as_str()),
_ => None,
})
.collect();
assert_eq!(
names,
vec!["review"],
"only the item matching BOTH search and kind survives: {names:?}"
);
}
#[test]
fn unmanaged_items_preserve_snapshot_kind_name_order() {
let snap = snap_with_unmanaged(vec![
make_unmanaged("alpha", ItemKind::Skill),
make_unmanaged("zeta", ItemKind::Skill),
make_unmanaged("beta", ItemKind::Agent),
]);
let nodes = build_tree(&snap, "", None, None, false, false);
let group = nodes
.iter()
.find(|n| matches!(n.node, TreeNode::UnmanagedGroup))
.expect("an unmanaged group node must be present");
let order: Vec<&str> = group
.children
.iter()
.filter_map(|n| match &n.node {
TreeNode::UnmanagedItem(i) => Some(i.name.as_str()),
_ => None,
})
.collect();
assert_eq!(
order,
vec!["alpha", "zeta", "beta"],
"node order must follow the snapshot order verbatim"
);
}
#[test]
fn unmanaged_group_sorts_after_installed_and_available() {
let mut snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![make_available("agent:dev", "dev", "src/a", ItemKind::Agent)],
);
snap.unmanaged = vec![make_unmanaged("x", ItemKind::Skill)];
let nodes = build_tree(&snap, "", None, None, false, false);
assert!(matches!(nodes[0].node, TreeNode::InstalledGroup));
assert!(matches!(nodes[1].node, TreeNode::AvailableGroup));
assert!(
matches!(nodes[2].node, TreeNode::UnmanagedGroup),
"unmanaged group must be the last root group"
);
assert_eq!(nodes.len(), 3);
}
#[test]
fn unmanaged_item_distinct_node_id_from_managed_same_name() {
let mut snap = snap_with(
vec![make_installed(
"skill:review",
"review",
"src/a",
ItemKind::Skill,
)],
vec![],
);
snap.unmanaged = vec![make_unmanaged("review", ItemKind::Skill)];
let nodes = build_tree(&snap, "", None, None, false, false);
let flat = flatten_tree(&nodes, &HashSet::new(), &HashSet::new());
let installed_id = flat
.iter()
.find(|n| matches!(&n.node, TreeNode::InstalledItem(i) if i.name == "review"))
.map(|n| n.id.clone())
.expect("managed review must be present");
let unmanaged_node = flat
.iter()
.find(|n| matches!(&n.node, TreeNode::UnmanagedItem(i) if i.name == "review"))
.expect("unmanaged review must be present");
assert_ne!(
installed_id, unmanaged_node.id,
"the colliding-name items must have distinct node ids"
);
assert_eq!(
unmanaged_node.id, "unmanaged:skill:review",
"the unmanaged node id is namespaced under 'unmanaged:'"
);
if let TreeNode::UnmanagedItem(i) = &unmanaged_node.node {
assert_eq!(i.key, "skill:review");
} else {
unreachable!();
}
}
#[test]
fn unmanaged_group_collapse_hides_items() {
let snap = snap_with_unmanaged(vec![make_unmanaged("x", ItemKind::Skill)]);
let nodes = build_tree(&snap, "", None, None, false, false);
let mut collapsed = HashSet::new();
collapsed.insert("group:unmanaged".to_string());
let flat = flatten_tree(&nodes, &HashSet::new(), &collapsed);
let item_visible = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::UnmanagedItem(_)));
let group_visible = flat
.iter()
.any(|n| matches!(&n.node, TreeNode::UnmanagedGroup));
assert!(!item_visible, "collapsed group hides its items");
assert!(
group_visible,
"the group header stays visible when collapsed"
);
}
}