use std::collections::BTreeMap;
use index_core::{IndexDocument, IndexNode, SectionRole};
use index_dom::{IndexDateStyle, IndexManifest};
pub fn apply_index_manifest_hints(document: &mut IndexDocument, manifest: &IndexManifest) {
let date_hints = manifest
.dates
.iter()
.map(|hint| (hint.field.trim().to_ascii_lowercase(), hint.style))
.collect::<BTreeMap<_, _>>();
let field_labels = manifest
.fields
.iter()
.filter_map(|hint| {
hint.label.as_ref().map(|label| {
(
hint.name.trim().to_ascii_lowercase(),
label.trim().to_owned(),
)
})
})
.collect::<BTreeMap<_, _>>();
let form_notes = manifest
.forms
.iter()
.filter_map(|hint| {
hint.note.as_ref().map(|note| {
(
hint.name.trim().to_ascii_lowercase(),
note.trim().to_owned(),
)
})
})
.collect::<BTreeMap<_, _>>();
apply_node_hints(
&mut document.nodes,
&date_hints,
&field_labels,
&form_notes,
&manifest.regions,
);
}
fn apply_node_hints(
nodes: &mut [IndexNode],
date_hints: &BTreeMap<String, IndexDateStyle>,
field_labels: &BTreeMap<String, String>,
form_notes: &BTreeMap<String, String>,
region_hints: &[index_dom::IndexRegionHint],
) {
for node in nodes {
match node {
IndexNode::Paragraph(text) => apply_date_hint_to_text(text, date_hints),
IndexNode::Table { rows } => {
for row in rows {
for cell in row {
apply_date_hint_to_text(cell, date_hints);
}
}
}
IndexNode::Form(form) => {
let mut fragments = Vec::new();
if let Some(note) = form_notes.get(&form.name.to_ascii_lowercase()) {
fragments.push(note.clone());
}
for input in &form.inputs {
if let Some(label) = field_labels.get(&input.name.to_ascii_lowercase()) {
fragments.push(format!("{}={}", input.name, label));
}
}
if !fragments.is_empty() && !form.name.contains("[hints:") {
form.name = format!("{} [hints: {}]", form.name, fragments.join(", "));
}
}
IndexNode::Section {
role,
collapsed,
nodes,
..
} => {
if let Some(next_collapsed) = region_collapsed_hint(*role, region_hints) {
*collapsed = next_collapsed;
}
apply_node_hints(nodes, date_hints, field_labels, form_notes, region_hints);
}
_ => {}
}
}
}
fn region_collapsed_hint(
role: SectionRole,
region_hints: &[index_dom::IndexRegionHint],
) -> Option<bool> {
let role_name = role.as_str();
region_hints
.iter()
.find(|hint| hint.role.eq_ignore_ascii_case(role_name))
.map(|hint| hint.collapsed)
}
fn apply_date_hint_to_text(text: &mut String, date_hints: &BTreeMap<String, IndexDateStyle>) {
let Some((raw_key, raw_value)) = text.split_once(':') else {
return;
};
let key = raw_key.trim().to_ascii_lowercase();
let Some(style) = date_hints.get(&key).copied() else {
return;
};
let value = raw_value.trim();
let formatted = match style {
IndexDateStyle::Date => format_date(value),
IndexDateStyle::DateTime => format_datetime(value),
};
*text = format!("{}: {}", raw_key.trim(), formatted);
}
fn format_date(value: &str) -> String {
if let Some((head, _)) = value.split_once('T') {
if is_iso_date(head) {
return head.to_owned();
}
}
if let Some((head, _)) = value.split_once(' ') {
if is_iso_date(head) {
return head.to_owned();
}
}
if value.len() >= 10 && is_iso_date(&value[..10]) {
return value[..10].to_owned();
}
value.to_owned()
}
fn format_datetime(value: &str) -> String {
if value.contains('T') {
return value.replacen('T', " ", 1);
}
if is_iso_date(value) {
return format!("{value} 00:00");
}
value.to_owned()
}
fn is_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use index_core::{ButtonAction, Form, IndexDocument, IndexNode, Input, SectionRole};
use index_dom::{
IndexContentHint, IndexDateHint, IndexDateStyle, IndexFieldHint, IndexFormHint,
IndexManifest, IndexRegionHint,
};
use super::{apply_index_manifest_hints, format_date, format_datetime, is_iso_date};
fn manifest() -> IndexManifest {
IndexManifest {
version: "index.idx/v1".to_owned(),
source_url: "https://example.org/.well-known/index.idx".to_owned(),
scope: "/".to_owned(),
content: IndexContentHint::default(),
regions: vec![IndexRegionHint {
role: "related".to_owned(),
selector: "aside.related".to_owned(),
collapsed: false,
}],
fields: vec![IndexFieldHint {
name: "updated".to_owned(),
label: Some("Updated".to_owned()),
}],
forms: vec![IndexFormHint {
name: "search".to_owned(),
selector: None,
note: Some("public search".to_owned()),
}],
dates: vec![
IndexDateHint {
field: "updated".to_owned(),
style: IndexDateStyle::Date,
},
IndexDateHint {
field: "published".to_owned(),
style: IndexDateStyle::DateTime,
},
],
}
}
#[test]
fn applies_date_field_form_and_region_hints() {
let mut document = IndexDocument::titled("Example");
document.nodes = vec![
IndexNode::Paragraph("Updated: 2026-05-11T12:34:00Z".to_owned()),
IndexNode::Paragraph("Published: 2026-05-01".to_owned()),
IndexNode::Section {
role: SectionRole::Related,
title: Some("Related".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Paragraph(
"Updated: 2026-05-02T08:00:00Z".to_owned(),
)],
},
IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.org/search".to_owned(),
inputs: vec![Input {
name: "updated".to_owned(),
kind: "text".to_owned(),
value: None,
required: false,
}],
buttons: vec![ButtonAction {
name: None,
value: None,
label: "Go".to_owned(),
}],
}),
];
apply_index_manifest_hints(&mut document, &manifest());
assert!(matches!(
document.nodes.first(),
Some(IndexNode::Paragraph(text)) if text == "Updated: 2026-05-11"
));
assert!(matches!(
document.nodes.get(1),
Some(IndexNode::Paragraph(text)) if text == "Published: 2026-05-01 00:00"
));
assert!(matches!(
document.nodes.get(2),
Some(IndexNode::Section { collapsed, nodes, .. })
if !collapsed && matches!(nodes.first(), Some(IndexNode::Paragraph(text)) if text == "Updated: 2026-05-02")
));
assert!(matches!(
document.nodes.get(3),
Some(IndexNode::Form(form)) if form.name.contains("public search") && form.name.contains("updated=Updated")
));
}
#[test]
fn format_helpers_cover_supported_and_fallback_shapes() {
assert_eq!(format_date("2026-05-11T12:34:00Z"), "2026-05-11");
assert_eq!(format_date("2026-05-11 12:34:00"), "2026-05-11");
assert_eq!(format_date("2026-05-11foobar"), "2026-05-11");
assert_eq!(format_date("not-a-date"), "not-a-date");
assert_eq!(
format_datetime("2026-05-11T12:34:00Z"),
"2026-05-11 12:34:00Z"
);
assert_eq!(format_datetime("2026-05-11"), "2026-05-11 00:00");
assert_eq!(format_datetime("not-a-date"), "not-a-date");
assert!(is_iso_date("2026-05-11"));
assert!(!is_iso_date("2026/05/11"));
assert!(!is_iso_date("short"));
}
#[test]
fn applies_hints_to_table_cells_and_avoids_duplicate_form_hint_suffixes() {
let mut document = IndexDocument::titled("Example");
document.nodes = vec![
IndexNode::Table {
rows: vec![vec![
"Updated: 2026-05-11T12:34:00Z".to_owned(),
"Other: keep-me".to_owned(),
]],
},
IndexNode::Form(Form {
name: "search [hints: preset]".to_owned(),
method: "GET".to_owned(),
action: "https://example.org/search".to_owned(),
inputs: vec![Input {
name: "updated".to_owned(),
kind: "text".to_owned(),
value: None,
required: false,
}],
buttons: vec![ButtonAction {
name: None,
value: None,
label: "Go".to_owned(),
}],
}),
];
apply_index_manifest_hints(&mut document, &manifest());
assert!(matches!(
document.nodes.first(),
Some(IndexNode::Table { rows })
if matches!(rows.first().and_then(|row| row.first()), Some(value) if value == "Updated: 2026-05-11")
&& matches!(rows.first().and_then(|row| row.get(1)), Some(value) if value == "Other: keep-me")
));
assert!(matches!(
document.nodes.get(1),
Some(IndexNode::Form(form))
if form.name == "search [hints: preset]"
));
}
#[test]
fn region_hints_match_case_insensitively_and_leave_unhinted_sections_unchanged() {
let mut manifest = manifest();
manifest.regions = vec![IndexRegionHint {
role: "RELATED".to_owned(),
selector: "aside.related".to_owned(),
collapsed: false,
}];
let mut document = IndexDocument::titled("Example");
document.nodes = vec![
IndexNode::Section {
role: SectionRole::Related,
title: Some("Related".to_owned()),
collapsed: true,
nodes: vec![],
},
IndexNode::Section {
role: SectionRole::Main,
title: Some("Main".to_owned()),
collapsed: false,
nodes: vec![],
},
];
apply_index_manifest_hints(&mut document, &manifest);
assert!(matches!(
document.nodes.first(),
Some(IndexNode::Section { collapsed, .. }) if !collapsed
));
assert!(matches!(
document.nodes.get(1),
Some(IndexNode::Section { collapsed, .. }) if !collapsed
));
}
}