archivist-core 0.2.0

Platform-neutral core for the FicHub companion bot: FicHub REST API client, search/recommendation/download command logic, intent classification, pagination cache, and the PlatformMessage IR. Shared by every platform adapter (Discord, Telegram, Matrix, Slack, IRC, fediverse, CLI, web).
Documentation
//! Copyable adapter skeleton — wire a new platform in ~100 lines.
//!
//! A real adapter is a thin 3-step pipeline around `archivist-core`:
//! **1. PARSE** (platform event → plain data: `SearchParams`, URL, page action)
//! → **2. DISPATCH** (call a core `dispatch::do_*` on a shared `CoreCtx`; all
//! command logic, pagination cache, rate limits and analytics live here)
//! → **3. RENDER** (match the returned `PlatformMessage` IR and emit natively).
//! The IR has four variants — `Text | Rich | File | Ephemeral` — and the
//! compiler forces you to handle all four in step 3.
//!
//! Reference adapters: `crates/fanfic-archivist` (Discord), `-matrix`,
//! `-telegram`, `-slack`, `-irc`, `-fediverse`, and `crates/fichub-cli` (its
//! `render.rs` is the simplest renderer to read first).
//!
//! Run offline: `cargo run -p archivist-core --example custom_adapter`.
//! The demo builds a real `CoreCtx` (offline `PageCache`, a client that never
//! issues a request) and renders hand-built messages; the commented `do_search`
//! line at the bottom shows the live path.

use std::sync::Arc;

use archivist_core::api::{FichubClient, SearchParams};
use archivist_core::cache::PageCache;
use archivist_core::config::BotConfig;
use archivist_core::core::{
    ActionRow, Button, ButtonStyle, PlatformMessage, RichItem, pagination_row,
};
use archivist_core::dispatch::CoreCtx;

// ── 0. CoreCtx construction ────────────────────────────────────────────
// One per process, shared by every handler (it is `Clone`).
fn build_core_ctx() -> CoreCtx {
    let config = Arc::new(BotConfig::default()); // env-driven; offline-safe defaults
    let client = FichubClient::new(config.clone()).expect("builds without I/O");
    let cache = PageCache::offline(); // no-op; every op succeeds, no Redis
    CoreCtx::new(client, cache, config)
}

// ── 1. PARSE ───────────────────────────────────────────────────────────
// Pure string logic → plain data. Real adapters put this in `parse.rs`.
fn parse_search(raw: &str) -> SearchParams {
    SearchParams {
        q: raw
            .strip_prefix("!search")
            .unwrap_or(raw)
            .trim()
            .to_string(),
        ..Default::default()
    }
}

// ── 3. RENDER ──────────────────────────────────────────────────────────
// Real adapters map: Text → plain msg, Rich → embed/HTML card + buttons,
// File → upload with caption, Ephemeral → private/visible-only-to-sender.
fn render(msg: &PlatformMessage) {
    match msg {
        PlatformMessage::Text(s) => println!("[text] {s}"),
        PlatformMessage::Rich {
            header,
            items,
            actions,
        } => {
            if let Some(h) = header {
                println!("[rich] {h}");
            }
            for item in items {
                if let Some(t) = &item.title {
                    println!("  {t}");
                }
                if let Some(u) = &item.url {
                    println!("  {u}");
                }
                if !item.body.is_empty() {
                    println!("  {}", item.body);
                }
                for (k, v) in &item.fields {
                    println!("  {k}: {v}");
                }
                if let Some(f) = &item.footer {
                    println!("{f}");
                }
            }
            for row in actions {
                match row {
                    ActionRow::Buttons(bs) => {
                        let labels: Vec<String> = bs.iter().map(button_label).collect();
                        println!("       buttons: {}", labels.join(" "));
                    }
                    ActionRow::SelectMenu {
                        placeholder,
                        options,
                    } => {
                        let opts: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
                        println!("       select \"{placeholder}\": {}", opts.join(", "));
                    }
                }
            }
        }
        PlatformMessage::File {
            filename,
            data,
            caption,
        } => {
            if let Some(c) = caption {
                println!("[file] {c}");
            }
            println!("       upload {filename} ({} bytes)", data.len());
        }
        // Private to the invoking user (Discord ephemeral, Matrix private
        // note, Telegram reply-only, ...).
        PlatformMessage::Ephemeral(inner) => {
            println!("[ephemeral →]");
            render(inner);
        }
    }
}

fn button_label(b: &Button) -> String {
    if b.label.is_empty() {
        b.emoji.clone().unwrap_or_default()
    } else {
        b.label.clone()
    }
}

fn main() {
    // 0: build the shared core context (offline-safe demo).
    let ctx = build_core_ctx();
    let user_id = 42;

    // 1 (PARSE): the adapter received `!search drarry` from the platform.
    let params = parse_search("!search drarry");
    let _ = (&ctx, params.q.len(), user_id); // consumed by the live path below

    // 2 (DISPATCH) — the live path:
    //   let msg = dispatch::do_search(&ctx, user_id, &params, None).await?;
    // That hits the FicHub REST API, so this deterministic offline demo renders
    // hand-built `PlatformMessage` values — exactly what any `do_*` returns.

    // A `Rich` reply as produced by `do_search`: header + items + pagination.
    let search_reply = PlatformMessage::Rich {
        header: Some("Search: drarry".into()),
        items: vec![
            RichItem::new("Harry Potter and the Pureblood Princess")
                .title("The Pureblood Princess")
                .url("https://archiveofourown.org/works/123")
                .field("Author", "SomeAuthor")
                .field("Words", "78,901")
                .field("Kudos", "12,345")
                .footer("1–5 of 238 results"),
            RichItem::new("Another fine fic")
                .title("Second Result")
                .url("https://archiveofourown.org/works/456")
                .field("Author", "OtherAuthor")
                .field("Words", "3,210"),
        ],
        actions: vec![pagination_row("sess-1", 238, 5, 1)],
    };

    // The other IR variants, so every render arm is exercised.
    let plain_text = PlatformMessage::Text("No results for 'xmascrack'.".into());
    let file_msg = PlatformMessage::File {
        filename: "The_Pureblood_Princess.epub".into(),
        data: b"PK\x03\x04 fake epub bytes".to_vec(),
        caption: Some("Here's your ebook".into()),
    };
    let ephemeral = PlatformMessage::ephemeral(PlatformMessage::Rich {
        header: Some("Private note".into()),
        items: vec![RichItem::new("Your download link is ready.")],
        actions: vec![ActionRow::Buttons(vec![Button {
            id: "dl:123".into(),
            label: "Download".into(),
            emoji: None,
            style: ButtonStyle::Primary,
            disabled: false,
        }])],
    });

    // 3 (RENDER): in a real adapter these four calls are a single
    // `render(msg)` fed from the `do_*` result.
    render(&search_reply);
    println!();
    render(&plain_text);
    println!();
    render(&file_msg);
    println!();
    render(&ephemeral);
}