index-transformer 1.0.0

Typestate transformer and orthogonal instruction set for Index.
Documentation
//! `index.idx` hint application for transformed documents.

use std::collections::BTreeMap;

use index_core::{IndexDocument, IndexNode, SectionRole};
use index_dom::{IndexDateStyle, IndexManifest};

/// Applies deterministic `index.idx` presentation hints to a transformed
/// `IndexDocument`.
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
        ));
    }
}