taba-cli 0.1.1-rc.2

The Taba client command line.
Documentation
//! Helpers for the tests of the interface: a model with the defaults, scripted replies, and a way to feed it
//! keys and clock ticks and look at the effects it asks for.

use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use taba_client::{Error, Reply};

use crate::tui::api::ApiCall;
use crate::tui::effect::Effect;
use crate::tui::i18n::Lang;
use crate::tui::model::{Model, Options};
use crate::tui::msg::Msg;
use crate::tui::theme::Theme;
use crate::tui::update::update;

pub const NODE_A: &str = "01a13640-0000-7000-8000-000000000001";
pub const NODE_B: &str = "01a13640-0000-7000-8000-000000000002";
pub const NODE_C: &str = "01a13640-0000-7000-8000-000000000003";

pub fn options(lang: Lang) -> Options {
    Options {
        lang,
        theme: Theme::default(),
        profile: "prod".to_owned(),
        url: "https://taba.example:8080".to_owned(),
        retention_secs: 3600,
        interval_secs: 5,
        profiles: crate::profile::ConfigFile::default(),
        choose_profile: false,
    }
}

pub fn me_root() -> &'static str {
    r#"{"authEnabled":true,"root":true,"id":null,"name":"root","permissions":["publish","consume","create","admin"],"topicPrefixes":[]}"#
}

pub fn me_plain() -> &'static str {
    r#"{"authEnabled":true,"root":false,"id":"x","name":"app","permissions":["publish"],"topicPrefixes":["app."]}"#
}

fn node_status(id: &str, used: u64, drain: &str, rejected: u32) -> String {
    format!(
        r#"{{"nodeId":"{id}","reachable":true,"status":{{"nodeId":"{id}","version":"1.4.0","clusterName":"prod-eu","mode":"production","state":"active","drainState":"{drain}","startedAt":"2026-10-15T08:00:00.000Z","collectedAt":"2026-10-15T12:00:00.000Z","storage":{{"memoryUsedBytes":{used},"memoryLimitBytes":1073741824,"memoryMessages":1200,"diskMessages":340000,"diskFreeBytes":53687091200}},"signals":{{"membersVersion":7,"quorumAvailable":true,"channels":2,"syncingBlocks":0,"blocksBelowReplication":0,"blocksBelowQuorum":0,"frozenNodes":0}},"runtime":{{"activeRequests":3.0,"rateLimitRejected":{rejected}.0,"channelRttP95Seconds":0.0123}}}}}}"#
    )
}

/// `GET /cluster/status` of three nodes; with `partial` the third did not answer.
pub fn status_json(partial: bool) -> String {
    let third = if partial {
        format!(r#"{{"nodeId":"{NODE_C}","reachable":false,"status":null}}"#)
    } else {
        node_status(NODE_C, 100_000_000, "active", 0)
    };
    let (conditions, worst, missing) = if partial {
        (
            r#"[{"condition":"member-unreachable","severity":"warning"}]"#,
            "member-unreachable",
            format!(r#"[{{"nodeId":"{NODE_C}","reason":"timeout"}}]"#),
        )
    } else {
        ("[]", "ok", "[]".to_owned())
    };
    format!(
        r#"{{"scope":"cluster","collectedAt":"2026-10-15T12:00:00.000Z","partial":{partial},"missingNodes":{missing},"worstCondition":"{worst}","conditions":{conditions},"summary":{{"members":{{"active":3}},"membersVersion":7,"quorumAvailable":true,"syncingBlocks":0}},"nodes":[{a},{b},{third}]}}"#,
        a = node_status(NODE_A, 805_306_368, "active", 5),
        b = node_status(NODE_B, 214_748_364, "active", 0),
    )
}

pub fn nodes_json() -> String {
    format!(
        r#"{{"collectedAt":"2026-10-15T12:00:00.000Z","membersVersion":7,"partial":false,"missingNodes":[],"items":[{{"nodeId":"{NODE_A}","self":true,"reachable":true,"state":"active","drainState":"active","version":"1.4.0","memberState":"active"}},{{"nodeId":"{NODE_B}","self":false,"reachable":true,"state":"active","drainState":"active","version":"1.4.0","memberState":"active"}}]}}"#
    )
}

/// `GET /tokens`: one that never expires, one that expires in three days and one that already expired.
pub fn tokens_json() -> &'static str {
    r#"{"items":[{"id":"0191f3a2-7c1e-7000-8000-00000000000a","name":"orders-app","permissions":["publish","consume"],"topicPrefixes":["orders."],"createdAt":"2026-10-01T10:00:00Z","expiresAt":null},{"id":"0191f3a2-7c1e-7000-8000-00000000000b","name":"billing","permissions":["admin"],"topicPrefixes":[],"createdAt":"2026-10-10T10:00:00Z","expiresAt":"2026-10-18T00:00:00Z"},{"id":"0191f3a2-7c1e-7000-8000-00000000000c","name":"old-batch","permissions":["consume"],"topicPrefixes":["batch.","etl."],"createdAt":"2026-09-01T10:00:00Z","expiresAt":"2026-10-01T00:00:00Z"}]}"#
}

pub fn topics_json() -> &'static str {
    r#"{"items":[{"id":"01a1-1","name":"orders","type":"queue","fifo":true,"volatile":false,"tags":{"team":"shop"},"createdAt":"2026-10-01T10:00:00Z","maxDeliveries":null,"maxMessageBytes":null,"ttl":3600,"effective":{"maxDeliveries":10,"maxMessageBytes":5242880,"ttl":3600}},{"id":"01a1-2","name":"orders-dead-letter","type":"queue","deadLetter":true,"source":"orders","fifo":false,"volatile":false,"tags":{},"createdAt":"2026-10-01T10:00:00Z"},{"id":"01a1-3","name":"fanout","type":"distributor","prefixes":["orders","billing"],"tags":{},"createdAt":"2026-10-12T10:00:00Z"},{"id":"01a1-4","name":"replies","type":"queue","volatile":true,"fifo":false,"idleTimeout":600,"tags":{},"createdAt":"2026-10-14T10:00:00Z","effective":{"maxDeliveries":10,"maxMessageBytes":5242880,"ttl":1209600}}]}"#
}

pub fn topic_stats_json() -> &'static str {
    r#"{"items":[{"name":"fanout"},{"name":"orders","depth":1500,"publishRate":12.5,"consumeRate":3.0,"oldestAge":125.0,"worstCondition":"backlog","conditions":[{"condition":"backlog","severity":"warning"}]},{"name":"orders-dead-letter","depth":0,"publishRate":0.0,"consumeRate":0.0,"worstCondition":"ok","conditions":[]},{"name":"replies","depth":7,"publishRate":0.2,"consumeRate":0.2,"oldestAge":3.0,"worstCondition":"ok","conditions":[]}],"partial":false}"#
}

/// One reading of `orders` for its charts; `step` makes the numbers grow so the lines are not flat.
pub fn topic_sample_json(step: u64) -> String {
    format!(
        r#"{{"name":"orders","visibleMessages":{},"invisibleMessages":{},"depth":{},"publishRate":{:.1},"consumeRate":{:.1},"ackRate":{:.1},"oldestAge":{},"topGroups":[{{"groupId":"customer-17","messages":900,"oldestAge":{}}},{{"groupId":"customer-4","messages":300,"oldestAge":{}}}],"partial":false}}"#,
        1000 + step * 200,
        20 + step * 5,
        1020 + step * 205,
        10.0 + step as f64,
        6.0 + step as f64 * 0.5,
        5.5 + step as f64 * 0.5,
        120 + step * 30,
        100 + step * 25,
        30 + step * 4
    )
}

/// `GET /topics/orders/messages` answering `count` messages, `m1`, `m2`, ...
pub fn messages_json(count: u32) -> String {
    let items: Vec<String> = (1..=count)
        .map(|n| {
            format!(
                r#"{{"id":"01a1-m{n}","body":{{"order":{n},"total":19.9}},"contentType":"application/json","receiptToken":"rt-{n}","deliveryCount":1,"publishedAt":"2026-10-16T10:00:00.000Z","invisibleUntil":"2026-10-16T10:00:30.000Z","groupId":"customer-{n}"}}"#
            )
        })
        .collect();
    format!(r#"{{"messages":[{}]}}"#, items.join(","))
}

pub fn ok(body: &str) -> Result<Reply, Error> {
    Ok(Reply {
        status: 200,
        headers: Vec::new(),
        body: body.as_bytes().to_vec(),
        request_id: None,
        recovered: false,
    })
}

pub fn fail(status: u16, code: &str, body: &str) -> Result<Reply, Error> {
    Err(Error {
        status,
        code: code.to_owned(),
        message: format!("{code} happened"),
        request_id: Some("req-1".to_owned()),
        retry_after: None,
        details: None,
        body: body.as_bytes().to_vec(),
    })
}

pub fn offline() -> Result<Reply, Error> {
    Err(Error::from_message("client.network", "connection refused"))
}

pub fn key(code: KeyCode) -> Msg {
    Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))
}

pub fn ch(c: char) -> Msg {
    key(KeyCode::Char(c))
}

/// A model and the log of the effects `update` has asked for.
pub struct Sim {
    pub m: Model,
    pub log: Vec<Effect>,
    wall: i64,
}

impl Sim {
    pub fn new(lang: Lang) -> Sim {
        Sim::with_options(options(lang))
    }

    pub fn with_options(options: Options) -> Sim {
        Sim {
            m: Model::new(options),
            log: Vec::new(),
            wall: 1_792_065_600_000,
        }
    }

    pub fn send(&mut self, msg: Msg) -> Vec<Effect> {
        let effects = update(&mut self.m, msg);
        self.log.extend(effects.clone());
        effects
    }

    pub fn press(&mut self, c: char) -> Vec<Effect> {
        self.send(ch(c))
    }

    pub fn code(&mut self, code: KeyCode) -> Vec<Effect> {
        self.send(key(code))
    }

    pub fn tick(&mut self, now: u64) -> Vec<Effect> {
        let wall_ms = self.wall + i64::try_from(now).unwrap_or(0);
        self.send(Msg::Tick { now, wall_ms })
    }

    /// Answers a call effect.
    pub fn answer(&mut self, effect: &Effect, result: Result<Reply, Error>) -> Vec<Effect> {
        let Effect::Call {
            id,
            generation,
            call,
        } = effect
        else {
            panic!("not a call: {effect:?}");
        };
        self.send(Msg::Reply {
            id: *id,
            generation: *generation,
            call: call.clone(),
            result,
        })
    }

    /// Ticks at `now` and returns the one call it asked for (or none).
    pub fn poll(&mut self, now: u64) -> Vec<ApiCall> {
        self.tick(now)
            .into_iter()
            .filter_map(|e| match e {
                Effect::Call { call, .. } => Some(call),
                _ => None,
            })
            .collect()
    }

    /// Opens the session as root with a healthy cluster, at time `1000`.
    pub fn root_with_status(lang: Lang) -> Sim {
        let mut sim = Sim::new(lang);
        let effects = sim.tick(0);
        sim.answer(&effects[0], ok(me_root()));
        let effects = sim.tick(100);
        sim.answer(&effects[0], ok(&status_json(false)));
        sim
    }

    pub fn with_status_json(lang: Lang, status: &str) -> Sim {
        let mut sim = Sim::new(lang);
        let effects = sim.tick(0);
        sim.answer(&effects[0], ok(me_root()));
        let effects = sim.tick(100);
        sim.answer(&effects[0], ok(status));
        sim
    }

    /// A root session on the Tokens tab with three tokens read (the clock is in October 2026).
    pub fn on_tokens(lang: Lang) -> Sim {
        let mut sim = Sim::root_with_status(lang);
        sim.press('3');
        let effects = sim.tick(1_000);
        let call = effects
            .iter()
            .find(|e| {
                matches!(
                    e,
                    Effect::Call {
                        call: ApiCall::Tokens(None),
                        ..
                    }
                )
            })
            .expect("the tab asks for the tokens")
            .clone();
        sim.answer(&call, ok(tokens_json()));
        sim
    }

    /// A root session on the Topics tab with its topics and their numbers read.
    pub fn on_topics(lang: Lang) -> Sim {
        let mut sim = Sim::root_with_status(lang);
        sim.press('2');
        let effects = sim.tick(1_000);
        for effect in &effects {
            let Effect::Call { call, .. } = effect else {
                continue;
            };
            match call {
                ApiCall::Topics(None) => {
                    sim.answer(effect, ok(topics_json()));
                }
                ApiCall::TopicStats(None) => {
                    sim.answer(effect, ok(topic_stats_json()));
                }
                _ => {}
            }
        }
        sim
    }

    /// The charts of the queue `orders` after five readings, 5 s apart, the last at 22 s.
    pub fn on_topic_charts(lang: Lang) -> Sim {
        let mut sim = Sim::on_topics(lang);
        sim.press('j');
        sim.press('g');
        for (step, now) in [2_000u64, 7_000, 12_000, 17_000, 22_000]
            .into_iter()
            .enumerate()
        {
            let effects = sim.tick(now);
            let asked = effects.iter().find(|e| {
                matches!(
                    e,
                    Effect::Call {
                        call: ApiCall::TopicDetail(_),
                        ..
                    }
                )
            });
            let effect = asked.expect("the charts ask for a reading").clone();
            sim.answer(&effect, ok(&topic_sample_json(step as u64)));
        }
        sim
    }

    /// The Messages view of the queue `orders`, opened with `x` from the list; nothing was read yet.
    pub fn on_topic_messages(lang: Lang) -> Sim {
        let mut sim = Sim::on_topics(lang);
        sim.press('j');
        sim.press('x');
        sim
    }

    pub fn plain_with_nodes(lang: Lang) -> Sim {
        let mut sim = Sim::new(lang);
        let effects = sim.tick(0);
        sim.answer(&effects[0], ok(me_plain()));
        let effects = sim.tick(100);
        assert_eq!(effects.len(), 1, "{effects:?}");
        sim.answer(&effects[0], ok(&nodes_json()));
        sim
    }
}