//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6). //! Nothing here is shortened: arguments too long to show whole are not shown at all. use proto::escape::escape_json_text; use proto::{DenyReason, PendingApproval, Timestamp, sha256}; /// Arguments are split into parts of at most this many characters. pub const PART_CHARS: usize = 15_000; /// At most this many parts; more are answerable only in `bxctl`. pub const MAX_PARTS: usize = 4; /// The longest a part's post may be, leaving room for the line added when it ends. pub const MAX_PART_POST: usize = 15_500; /// An approval's posts, in order. Only an answer after the last counts; when `answerable` is /// false, no command answers it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Shown { pub posts: Vec, pub answerable: bool, } /// How an approval ended, for the line added to its last post. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Ended { Runs { by: String }, Denied { by: String, reason: DenyReason }, Refused { by: String, reason: Option }, NoLonger, Restarted, } /// `text` as inline code, with a backtick run longer than any inside it. pub fn code(text: &str) -> String { // `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when // `text` starts or ends with a backtick (CommonMark needs it), else none: // `format!("{ticks}{pad}{text}{pad}{ticks}")`. todo!() } /// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3). pub fn fence(text: &str) -> String { // `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then // `format!("{ticks}\n{text}\n{ticks}")`. todo!() } fn longest_run(text: &str) -> usize { // The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a // backtick and back to 0 on anything else, keeping the most seen. todo!() } /// `2026-09-24 14:47 UTC`. fn minute(at: Timestamp) -> String { // `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC", // rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`. todo!() } /// A serde name (`private`, `no_grant`), or the debug form if there is none. fn name(value: &T) -> String { // `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else // -> `format!("{value:?}")`. todo!() } fn reply_line(id: u64) -> String { // `format!("Reply in this thread with `!approve {id}` or `!deny {id} `.")`. todo!() } /// The posts that show `info`. pub fn render(info: &PendingApproval) -> Shown { let id = info.approval; let args = escape_json_text(&info.arguments); let header = format!( "**Approval {id}** · tool {} · grant {} · taint {} · expires {}", code(&escape_json_text(&info.tool)), code(&escape_json_text(&info.grant)), name(&info.taint), minute(info.expires), ); let chars: Vec = args.chars().collect(); let parts: Vec = if chars.is_empty() { vec![String::new()] } else { chars .chunks(PART_CHARS) .map(|c| c.iter().collect()) .collect() }; let count = parts.len(); let posts: Vec = if count == 1 { vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))] } else { let hash = sha256(args.as_bytes()) .map(|h| h.to_hex()) .unwrap_or_default(); parts .iter() .enumerate() .map(|(i, part)| { let n = i + 1; let top = if n == 1 { format!("{header}\nPart 1 of {count} of the arguments:") } else { format!("**Approval {id}**, part {n} of {count} of the arguments:") }; let mut post = format!("{top}\n{}", fence(part)); if n == count { post.push_str(&format!( "\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}", chars.len(), reply_line(id) )); } post }) .collect() }; if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) { let hash = sha256(args.as_bytes()) .map(|h| h.to_hex()) .unwrap_or_default(); let post = format!( "{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.", chars.len() ); return Shown { posts: vec![post], answerable: false, }; } Shown { posts, answerable: true, } } /// The line added to the last post when the approval ends. `by` is `@username`, or the id. pub fn ended_line(ended: &Ended) -> String { // A `match ended`, one line each, exactly (`by` as given): // 1. Runs: "✅ Approved by {by}: the call runs." // 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}." // 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}." // 4. Refused with None: "❌ Refused by {by}: no reason given." // 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)." // 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is // posted again." todo!() } /// The last post's text with the ending line added. pub fn with_ending(last: &str, ended: &Ended) -> String { // `format!("{last}\n\n{}", ended_line(ended))`. todo!() }