use crate::model::{LocalizedText, Vocabulary, VocabularyDetail, VocabularyHeader, VocabularyNode};
const LANGUAGE_ORDER: [&str; 5] = ["en", "de", "fr", "it", "rm"];
pub(crate) fn localized_column(labels: &[LocalizedText], lang: Option<&str>) -> String {
labels
.iter()
.find(|l| l.language.as_deref() == lang)
.map(|l| l.value.clone())
.unwrap_or_default()
}
pub(crate) fn join_labels_prose(labels: &[LocalizedText]) -> String {
let mut parts: Vec<String> = Vec::new();
for lang in LANGUAGE_ORDER {
let v = localized_column(labels, Some(lang));
if !v.is_empty() {
parts.push(v);
}
}
let untagged = localized_column(labels, None);
if !untagged.is_empty() {
parts.push(untagged);
}
parts.join(" / ")
}
pub(crate) fn localized_path_segment(header: &VocabularyHeader) -> String {
if header.labels.is_empty() {
return local_name(&header.iri);
}
for lang in LANGUAGE_ORDER {
let v = localized_column(&header.labels, Some(lang));
if !v.is_empty() {
return v;
}
}
localized_column(&header.labels, None)
}
fn local_name(iri: &str) -> String {
let slash = iri.rfind('/');
let hash = iri.rfind('#');
match slash.max(hash) {
Some(pos) => iri[pos + 1..].to_string(),
None => iri.to_string(),
}
}
pub(crate) fn build_vocabulary_list_rows(items: &[Vocabulary]) -> Vec<Vec<String>> {
items
.iter()
.map(|item| {
let h = &item.header;
let mut row = vec![h.name.clone().unwrap_or_default(), h.iri.clone()];
for lang in LANGUAGE_ORDER {
row.push(localized_column(&h.labels, Some(lang)));
}
row.push(localized_column(&h.labels, None));
for lang in LANGUAGE_ORDER {
row.push(localized_column(&h.comments, Some(lang)));
}
row.push(localized_column(&h.comments, None));
row.push(item.node_count.map(|n| n.to_string()).unwrap_or_default());
row.push(item.depth.map(|d| d.to_string()).unwrap_or_default());
row
})
.collect()
}
pub(crate) struct FlatVocabularyNode<'a> {
pub(crate) header: &'a VocabularyHeader,
pub(crate) number: String,
pub(crate) path: String,
pub(crate) depth: usize,
pub(crate) position: i32,
pub(crate) parent_iri: String,
}
pub(crate) fn flatten_vocabulary_detail(detail: &VocabularyDetail) -> Vec<FlatVocabularyNode<'_>> {
let mut flat: Vec<FlatVocabularyNode> = Vec::new();
let root_path_segment = localized_path_segment(&detail.tree.root);
walk(
&detail.tree.children,
"",
&root_path_segment,
0,
&detail.tree.root.iri,
&mut flat,
);
match &detail.subtree_of {
None => flat,
Some(target_iri) => {
let target_number = flat
.iter()
.find(|n| &n.header.iri == target_iri)
.map(|n| n.number.clone());
match target_number {
None => Vec::new(),
Some(target_number) => {
let prefix = format!("{target_number}.");
flat.into_iter()
.filter(|n| n.number == target_number || n.number.starts_with(&prefix))
.collect()
}
}
}
}
}
fn child_number(number_prefix: &str, position: i32) -> String {
if number_prefix.is_empty() {
(position + 1).to_string()
} else {
format!("{number_prefix}.{}", position + 1)
}
}
fn walk<'a>(
nodes: &'a [VocabularyNode],
number_prefix: &str,
path_prefix: &str,
depth: usize,
parent_iri: &str,
out: &mut Vec<FlatVocabularyNode<'a>>,
) {
for node in nodes {
let number = child_number(number_prefix, node.position);
let segment = localized_path_segment(&node.header);
let path = if path_prefix.is_empty() {
segment
} else {
format!("{path_prefix} \u{203a} {segment}")
};
let this_depth = depth + 1;
out.push(FlatVocabularyNode {
header: &node.header,
number: number.clone(),
path: path.clone(),
depth: this_depth,
position: node.position,
parent_iri: parent_iri.to_string(),
});
walk(
&node.children,
&number,
&path,
this_depth,
&node.header.iri,
out,
);
}
}
pub(crate) fn build_vocabulary_rows(detail: &VocabularyDetail) -> Vec<Vec<String>> {
flatten_vocabulary_detail(detail)
.into_iter()
.map(|n| {
let h = n.header;
let mut row = vec![h.iri.clone(), n.number, h.name.clone().unwrap_or_default()];
for lang in LANGUAGE_ORDER {
row.push(localized_column(&h.labels, Some(lang)));
}
row.push(localized_column(&h.labels, None));
for lang in LANGUAGE_ORDER {
row.push(localized_column(&h.comments, Some(lang)));
}
row.push(localized_column(&h.comments, None));
row.push(n.path);
row.push(n.position.to_string());
row.push(n.depth.to_string());
row.push(n.parent_iri);
row
})
.collect()
}
pub(crate) struct NestedVocabularyNode<'a> {
pub(crate) header: &'a VocabularyHeader,
pub(crate) number: String,
pub(crate) position: i32,
pub(crate) children: Vec<NestedVocabularyNode<'a>>,
}
pub(crate) fn nest_vocabulary_detail(detail: &VocabularyDetail) -> Vec<NestedVocabularyNode<'_>> {
let whole = nest_children(&detail.tree.children, "");
match &detail.subtree_of {
None => whole,
Some(target_iri) => match take_node(whole, target_iri) {
Some(node) => vec![node],
None => Vec::new(),
},
}
}
fn nest_children<'a>(
nodes: &'a [VocabularyNode],
number_prefix: &str,
) -> Vec<NestedVocabularyNode<'a>> {
nodes
.iter()
.map(|node| {
let number = child_number(number_prefix, node.position);
let children = nest_children(&node.children, &number);
NestedVocabularyNode {
header: &node.header,
number,
position: node.position,
children,
}
})
.collect()
}
fn take_node<'a>(
nodes: Vec<NestedVocabularyNode<'a>>,
target_iri: &str,
) -> Option<NestedVocabularyNode<'a>> {
for node in nodes {
if node.header.iri == target_iri {
return Some(node);
}
if let Some(found) = take_node(node.children, target_iri) {
return Some(found);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn text(value: &str, language: Option<&str>) -> LocalizedText {
LocalizedText {
value: value.to_string(),
language: language.map(|s| s.to_string()),
}
}
fn header(iri: &str, name: Option<&str>, labels: Vec<LocalizedText>) -> VocabularyHeader {
VocabularyHeader {
iri: iri.to_string(),
name: name.map(|s| s.to_string()),
labels,
comments: vec![],
}
}
#[test]
fn localized_column_returns_matching_language() {
let labels = vec![text("Period", Some("en")), text("A3 Period", Some("de"))];
assert_eq!(localized_column(&labels, Some("en")), "Period");
assert_eq!(localized_column(&labels, Some("de")), "A3 Period");
}
#[test]
fn localized_column_empty_when_absent() {
let labels = vec![text("Period", Some("en"))];
assert_eq!(localized_column(&labels, Some("fr")), "");
}
#[test]
fn localized_column_untagged_slot_uses_none() {
let labels = vec![text("untagged value", None), text("Period", Some("en"))];
assert_eq!(localized_column(&labels, None), "untagged value");
}
#[test]
fn localized_column_duplicate_tag_first_wins() {
let labels = vec![text("first", Some("en")), text("second", Some("en"))];
assert_eq!(localized_column(&labels, Some("en")), "first");
}
#[test]
fn join_labels_prose_fixed_order_regardless_of_input_order() {
let order_a = vec![text("Period", Some("en")), text("A3 Period", Some("de"))];
let order_b = vec![text("A3 Period", Some("de")), text("Period", Some("en"))];
assert_eq!(join_labels_prose(&order_a), "Period / A3 Period");
assert_eq!(join_labels_prose(&order_b), "Period / A3 Period");
}
#[test]
fn join_labels_prose_full_five_languages_plus_untagged() {
let labels = vec![
text("rm-val", Some("rm")),
text("it-val", Some("it")),
text("untagged-val", None),
text("fr-val", Some("fr")),
text("de-val", Some("de")),
text("en-val", Some("en")),
];
assert_eq!(
join_labels_prose(&labels),
"en-val / de-val / fr-val / it-val / rm-val / untagged-val"
);
}
#[test]
fn join_labels_prose_empty_when_no_labels() {
assert_eq!(join_labels_prose(&[]), "");
}
#[test]
fn localized_path_segment_prefers_english() {
let h = header(
"http://rdfh.ch/lists/0838/x",
None,
vec![text("A3 Period", Some("de")), text("Period", Some("en"))],
);
assert_eq!(localized_path_segment(&h), "Period");
}
#[test]
fn localized_path_segment_falls_back_through_chain_to_french_only() {
let h = header(
"http://rdfh.ch/lists/0838/x",
None,
vec![text("Phase de recherche", Some("fr"))],
);
assert_eq!(localized_path_segment(&h), "Phase de recherche");
}
#[test]
fn localized_path_segment_untagged_when_no_tagged_language_present() {
let h = header(
"http://rdfh.ch/lists/0838/x",
None,
vec![text("untagged value", None)],
);
assert_eq!(localized_path_segment(&h), "untagged value");
}
#[test]
fn localized_path_segment_falls_back_to_iri_local_name_with_slash() {
let h = header(
"http://rdfh.ch/lists/0838/JbNT7lvfS9yaB5bgbkoa2w",
None,
vec![],
);
assert_eq!(localized_path_segment(&h), "JbNT7lvfS9yaB5bgbkoa2w");
}
#[test]
fn localized_path_segment_falls_back_to_iri_local_name_with_hash() {
let h = header("http://example.com/ontology#Node", None, vec![]);
assert_eq!(localized_path_segment(&h), "Node");
}
#[test]
fn build_vocabulary_list_rows_one_row_per_item_with_correct_columns() {
let items = vec![
Vocabulary {
header: header(
"http://rdfh.ch/lists/0838/epoch",
Some("epoch"),
vec![text("Period", Some("en")), text("A3 Period", Some("de"))],
),
node_count: Some(33),
depth: Some(3),
},
Vocabulary {
header: header("http://rdfh.ch/lists/0838/other", Some("other"), vec![]),
node_count: None,
depth: None,
},
];
let rows = build_vocabulary_list_rows(&items);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].len(), 16);
assert_eq!(rows[0][0], "epoch");
assert_eq!(rows[0][1], "http://rdfh.ch/lists/0838/epoch");
assert_eq!(rows[0][2], "Period"); assert_eq!(rows[0][3], "A3 Period"); assert_eq!(rows[0][14], "33"); assert_eq!(rows[0][15], "3"); assert_eq!(rows[1][14], "");
assert_eq!(rows[1][15], "");
}
fn fixture_detail(subtree_of: Option<&str>) -> VocabularyDetail {
use crate::model::VocabularyTree;
let node2b1 = VocabularyNode {
header: header(
"http://rdfh.ch/lists/0001/node2b1",
Some("node2b1"),
vec![
text("Node 2b1 (en)", Some("en")),
text("Knoten 2b1", Some("de")),
],
),
position: 0,
children: vec![],
};
let node2b = VocabularyNode {
header: header(
"http://rdfh.ch/lists/0001/node2b",
Some("node2b"),
vec![
text("Node 2b (en)", Some("en")),
text("Knoten 2b", Some("de")),
],
),
position: 1,
children: vec![node2b1],
};
let node2a = VocabularyNode {
header: header(
"http://rdfh.ch/lists/0001/node2a",
Some("node2a"),
vec![text("Node 2a (en)", Some("en"))],
),
position: 0,
children: vec![],
};
let node2 = VocabularyNode {
header: header(
"http://rdfh.ch/lists/0001/node2",
Some("node2"),
vec![
text("Node 2 (en)", Some("en")),
text("Knoten 2", Some("de")),
],
),
position: 1,
children: vec![node2a, node2b],
};
let node1 = VocabularyNode {
header: header(
"http://rdfh.ch/lists/0001/node1",
Some("node1"),
vec![
text("Node 1 (en)", Some("en")),
text("Knoten 1", Some("de")),
],
),
position: 0,
children: vec![],
};
let tree = VocabularyTree {
root: header(
"http://rdfh.ch/lists/0001/root",
Some("root"),
vec![text("Phase de recherche", Some("fr"))],
),
children: vec![node1, node2],
project_iri: "http://rdfh.ch/projects/0001".into(),
requested_node: None,
};
let (node_count, depth) = tree.count_and_depth(subtree_of);
VocabularyDetail {
tree,
subtree_of: subtree_of.map(|s| s.to_string()),
node_count,
depth,
}
}
#[test]
fn build_vocabulary_rows_one_row_per_node_in_dfs_order() {
let detail = fixture_detail(None);
let rows = build_vocabulary_rows(&detail);
assert_eq!(rows.len(), 5);
let ids: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
assert_eq!(
ids,
vec![
"http://rdfh.ch/lists/0001/node1",
"http://rdfh.ch/lists/0001/node2",
"http://rdfh.ch/lists/0001/node2a",
"http://rdfh.ch/lists/0001/node2b",
"http://rdfh.ch/lists/0001/node2b1",
]
);
}
#[test]
fn build_vocabulary_rows_number_is_one_based_and_segment_count_equals_depth() {
let detail = fixture_detail(None);
let rows = build_vocabulary_rows(&detail);
let by_iri = |iri: &str| rows.iter().find(|r| r[0] == iri).unwrap();
let node1 = by_iri("http://rdfh.ch/lists/0001/node1");
assert_eq!(node1[1], "1"); assert_eq!(node1[17], "1"); assert_eq!(node1[16], "0");
let node2 = by_iri("http://rdfh.ch/lists/0001/node2");
assert_eq!(node2[1], "2");
let node2a = by_iri("http://rdfh.ch/lists/0001/node2a");
assert_eq!(node2a[1], "2.1");
assert_eq!(node2a[17], "2");
let node2b1 = by_iri("http://rdfh.ch/lists/0001/node2b1");
assert_eq!(node2b1[1], "2.2.1");
assert_eq!(node2b1[17], "3");
assert_eq!(node2b1[16], "0");
}
#[test]
fn build_vocabulary_rows_path_breadcrumb_mixed_language_fallback() {
let detail = fixture_detail(None);
let rows = build_vocabulary_rows(&detail);
let by_iri = |iri: &str| rows.iter().find(|r| r[0] == iri).unwrap();
let node2a = by_iri("http://rdfh.ch/lists/0001/node2a");
assert_eq!(
node2a[15],
"Phase de recherche \u{203a} Node 2 (en) \u{203a} Node 2a (en)"
);
}
#[test]
fn build_vocabulary_rows_parent_iri_correctness() {
let detail = fixture_detail(None);
let rows = build_vocabulary_rows(&detail);
let by_iri = |iri: &str| rows.iter().find(|r| r[0] == iri).unwrap();
let node1 = by_iri("http://rdfh.ch/lists/0001/node1");
assert_eq!(node1[18], "http://rdfh.ch/lists/0001/root");
let node2b1 = by_iri("http://rdfh.ch/lists/0001/node2b1");
assert_eq!(node2b1[18], "http://rdfh.ch/lists/0001/node2b");
}
#[test]
fn build_vocabulary_rows_subtree_narrows_and_keeps_absolute_numbers() {
let whole = fixture_detail(None);
let whole_rows = build_vocabulary_rows(&whole);
let whole_node2_number = whole_rows
.iter()
.find(|r| r[0] == "http://rdfh.ch/lists/0001/node2")
.unwrap()[1]
.clone();
let subtree = fixture_detail(Some("http://rdfh.ch/lists/0001/node2"));
let subtree_rows = build_vocabulary_rows(&subtree);
assert_eq!(subtree_rows.len(), 4);
let ids: Vec<&str> = subtree_rows.iter().map(|r| r[0].as_str()).collect();
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2"));
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2a"));
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2b"));
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2b1"));
assert!(!ids.contains(&"http://rdfh.ch/lists/0001/node1"));
let subtree_node2_number = subtree_rows
.iter()
.find(|r| r[0] == "http://rdfh.ch/lists/0001/node2")
.unwrap()[1]
.clone();
assert_eq!(subtree_node2_number, whole_node2_number);
assert_eq!(subtree_rows.len(), subtree.node_count);
}
#[test]
fn build_vocabulary_rows_subtree_leaf_yields_exactly_one_row() {
let detail = fixture_detail(Some("http://rdfh.ch/lists/0001/node2a"));
let rows = build_vocabulary_rows(&detail);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0], "http://rdfh.ch/lists/0001/node2a");
}
#[test]
fn build_vocabulary_rows_row_count_equals_node_count_whole_vocabulary() {
let detail = fixture_detail(None);
let rows = build_vocabulary_rows(&detail);
assert_eq!(rows.len(), detail.node_count);
assert_eq!(detail.node_count, 5);
}
#[test]
fn nest_vocabulary_detail_whole_tree_has_expected_shape() {
let detail = fixture_detail(None);
let nested = nest_vocabulary_detail(&detail);
assert_eq!(nested.len(), 2);
let node2 = nested
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2")
.unwrap();
assert_eq!(node2.children.len(), 2);
let node2b = node2
.children
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2b")
.unwrap();
assert_eq!(node2b.children.len(), 1);
}
#[test]
fn nest_vocabulary_detail_absolute_numbers_match_flat_builder() {
let detail = fixture_detail(None);
let flat_rows = build_vocabulary_rows(&detail);
let flat_node2b1_number = flat_rows
.iter()
.find(|r| r[0] == "http://rdfh.ch/lists/0001/node2b1")
.unwrap()[1]
.clone();
assert_eq!(flat_node2b1_number, "2.2.1");
let nested = nest_vocabulary_detail(&detail);
let node2 = nested
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2")
.unwrap();
let node2b = node2
.children
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2b")
.unwrap();
let node2b1 = node2b
.children
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2b1")
.unwrap();
assert_eq!(node2b1.number, flat_node2b1_number);
}
#[test]
fn nest_vocabulary_detail_subtree_returns_single_branch() {
let detail = fixture_detail(Some("http://rdfh.ch/lists/0001/node2"));
let nested = nest_vocabulary_detail(&detail);
assert_eq!(nested.len(), 1);
let node2 = &nested[0];
assert_eq!(node2.header.iri, "http://rdfh.ch/lists/0001/node2");
let ids: Vec<&str> = node2
.children
.iter()
.map(|n| n.header.iri.as_str())
.collect();
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2a"));
assert!(ids.contains(&"http://rdfh.ch/lists/0001/node2b"));
let node2b = node2
.children
.iter()
.find(|n| n.header.iri == "http://rdfh.ch/lists/0001/node2b")
.unwrap();
assert_eq!(node2b.children.len(), 1);
assert_eq!(
node2b.children[0].header.iri,
"http://rdfh.ch/lists/0001/node2b1"
);
assert_ne!(node2.header.iri, "http://rdfh.ch/lists/0001/node1");
}
#[test]
fn nest_vocabulary_detail_leaf_subtree_returns_one_node_no_children() {
let detail = fixture_detail(Some("http://rdfh.ch/lists/0001/node2a"));
let nested = nest_vocabulary_detail(&detail);
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].header.iri, "http://rdfh.ch/lists/0001/node2a");
assert!(nested[0].children.is_empty());
}
}