kcode-kennedy-box-text-objects 0.1.0

Stages Kennedy session box text as pending objects
Documentation
//! Stages canonical Kennedy session box text as pending objects.

#![forbid(unsafe_code)]

use anyhow::Context as _;
use kcode_session_history::{
    Session,
    chatend::{BoxId, EventId, PendingId},
};
use serde_json::{Value, json};

const BOX_TEXT_OBJECT_SOURCE: &str = "kennedy-box-text";
const BOX_TEXT_MEDIA_TYPE: &str = "text/plain; charset=utf-8";

#[derive(Debug, Eq, PartialEq)]
struct BoxTextObject {
    box_id: BoxId,
    pending_id: PendingId,
    reused: bool,
}

pub fn stage_box_text_objects(
    journal: &mut Session,
    box_ids: &[BoxId],
    recorded_at: &str,
) -> anyhow::Result<String> {
    let selections = box_ids
        .iter()
        .map(|box_id| {
            let state = journal
                .state()
                .box_state(*box_id)
                .with_context(|| format!("box {box_id} does not exist"))?;
            anyhow::ensure!(state.active, "box {box_id} is not active");
            anyhow::ensure!(
                !state.canonical.content.text.is_empty(),
                "box {box_id} has no text content"
            );
            Ok((
                *box_id,
                state.name.clone(),
                state.canonical.event_id,
                state.canonical.content.text.clone(),
            ))
        })
        .collect::<anyhow::Result<Vec<_>>>()?;

    let mut objects = Vec::with_capacity(selections.len());
    for (box_id, box_name, canonical_event_id, text) in selections {
        if let Some(pending_id) = existing_box_text_object(journal, box_id, canonical_event_id) {
            objects.push(BoxTextObject {
                box_id,
                pending_id,
                reused: true,
            });
            continue;
        }
        let pending_id = journal.stage_object(
            recorded_at,
            BOX_TEXT_MEDIA_TYPE,
            Some(format!("box-{box_id}.txt")),
            json!({
                "source":BOX_TEXT_OBJECT_SOURCE,
                "boxId":box_id.0,
                "boxName":box_name,
                "canonicalEventId":canonical_event_id.0,
            }),
            text.as_bytes(),
        )?;
        objects.push(BoxTextObject {
            box_id,
            pending_id,
            reused: false,
        });
    }
    Ok(render_box_text_objects(&objects))
}

fn existing_box_text_object(
    journal: &Session,
    box_id: BoxId,
    canonical_event_id: EventId,
) -> Option<PendingId> {
    journal
        .objects()
        .iter()
        .find(|(_, location)| {
            let transport = &location.metadata.transport;
            transport.get("source").and_then(Value::as_str) == Some(BOX_TEXT_OBJECT_SOURCE)
                && transport.get("boxId").and_then(Value::as_u64) == Some(box_id.0)
                && transport.get("canonicalEventId").and_then(Value::as_u64)
                    == Some(canonical_event_id.0)
        })
        .map(|(pending_id, _)| pending_id.clone())
}

fn render_box_text_objects(objects: &[BoxTextObject]) -> String {
    let mut text = String::from("Box text objects:");
    for object in objects {
        text.push_str(&format!("\nBox {}: {}", object.box_id, object.pending_id));
        if object.reused {
            text.push_str(" (already staged)");
        }
    }
    text.push_str(
        "\nThese pending object references resolve to canonical object IDs when the logical session commits.",
    );
    text
}