nmp-threading 1.0.0-rc.1

Reply-convention-agnostic timeline grouping algorithm. Owns ThreadPointer / ParentResolver / ModulePolicy / TimelineBlock / Grouper, consumed by per-NIP wrapper view modules (NIP-10 in nmp-nip01). No app nouns, no kind semantics.
Documentation
use crate::{ThreadEdge, ThreadPointer, ThreadingSnapshot, TimelineBlock};

use super::fb;

pub fn decode_threading_snapshot(bytes: &[u8]) -> Result<ThreadingSnapshot, String> {
    if bytes.len() < 8 || !fb::threading_snapshot_buffer_has_identifier(bytes) {
        return Err("missing NTHR file identifier".to_string());
    }
    let snapshot = fb::root_as_threading_snapshot(bytes)
        .map_err(|err| format!("not a valid ThreadingSnapshot buffer: {err}"))?;
    Ok(ThreadingSnapshot {
        edges: decode_edges(snapshot.edges())?,
        blocks: decode_blocks(snapshot.blocks())?,
        pending_ancestor_ids: decode_ids(snapshot.pending_ancestor_ids())?,
    })
}

fn decode_edges(
    edges: Option<flatbuffers::Vector<'_, flatbuffers::ForwardsUOffset<fb::ThreadEdge<'_>>>>,
) -> Result<Vec<ThreadEdge>, String> {
    let mut out = Vec::new();
    if let Some(edges) = edges {
        out.reserve(edges.len());
        for edge in edges.iter() {
            out.push(decode_edge(edge)?);
        }
    }
    Ok(out)
}

fn decode_edge(edge: fb::ThreadEdge<'_>) -> Result<ThreadEdge, String> {
    Ok(ThreadEdge {
        event_id: str_field(edge.event_id(), "ThreadEdge.event_id")?,
        author_pubkey: str_field(edge.author_pubkey(), "ThreadEdge.author_pubkey")?,
        kind: edge.kind(),
        created_at: edge.created_at(),
        parent: edge.parent().map(decode_pointer).transpose()?,
        root: edge.root().map(decode_pointer).transpose()?,
        parent_author_pubkey: edge.parent_author_pubkey().map(str::to_string),
    })
}

fn decode_blocks(
    blocks: Option<
        flatbuffers::Vector<'_, flatbuffers::ForwardsUOffset<fb::TimelineBlockEntry<'_>>>,
    >,
) -> Result<Vec<TimelineBlock>, String> {
    let mut out = Vec::new();
    if let Some(blocks) = blocks {
        out.reserve(blocks.len());
        for block in blocks.iter() {
            out.push(decode_block(block)?);
        }
    }
    Ok(out)
}

fn decode_block(block: fb::TimelineBlockEntry<'_>) -> Result<TimelineBlock, String> {
    match block.kind() {
        fb::TimelineBlockKind::Standalone => Ok(TimelineBlock::Standalone {
            id: str_field(block.standalone_id(), "TimelineBlockEntry.standalone_id")?,
            root: block.standalone_root().map(decode_pointer).transpose()?,
        }),
        fb::TimelineBlockKind::Module => Ok(TimelineBlock::Module {
            events: decode_ids(block.module_event_ids())?,
            has_gap: block.module_has_gap(),
            root: block.module_root().map(decode_pointer).transpose()?,
        }),
        other => Err(format!("unknown TimelineBlockKind: {other:?}")),
    }
}

fn decode_pointer(pointer: fb::ThreadPointer<'_>) -> Result<ThreadPointer, String> {
    let kind = optional_kind_num(pointer.has_kind_num(), pointer.kind_num());
    let relay = pointer.relay().map(str::to_string);
    match pointer.kind() {
        fb::ThreadPointerKind::Event => Ok(ThreadPointer::Event {
            id: str_field(pointer.id(), "ThreadPointer.id")?,
            relay,
            kind,
        }),
        fb::ThreadPointerKind::Address => Ok(ThreadPointer::Address {
            coord: str_field(pointer.coord(), "ThreadPointer.coord")?,
            relay,
            kind,
        }),
        fb::ThreadPointerKind::External => Ok(ThreadPointer::External {
            uri: str_field(pointer.uri(), "ThreadPointer.uri")?,
        }),
        other => Err(format!("unknown ThreadPointerKind: {other:?}")),
    }
}

fn decode_ids(
    ids: Option<flatbuffers::Vector<'_, flatbuffers::ForwardsUOffset<fb::BlockEventId<'_>>>>,
) -> Result<Vec<String>, String> {
    let mut out = Vec::new();
    if let Some(ids) = ids {
        out.reserve(ids.len());
        for id in ids.iter() {
            out.push(str_field(id.id(), "BlockEventId.id")?);
        }
    }
    Ok(out)
}

fn str_field(value: Option<&str>, ctx: &str) -> Result<String, String> {
    value
        .map(str::to_string)
        .ok_or_else(|| format!("{ctx}: missing required string field"))
}

const fn optional_kind_num(present: bool, value: u32) -> Option<u32> {
    if present {
        Some(value)
    } else {
        None
    }
}