Files
boxmaker/crates/gatewayd/src/approvals/text.rs
T
kyle d9f8fa9331 gatewayd: approvals::text, the posts that show an approval
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-24 04:42:37 -07:00

176 lines
5.8 KiB
Rust

//! 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<String>,
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<String> },
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);
let pad = if text.starts_with('`') || text.ends_with('`') {
" "
} else {
""
};
format!("{ticks}{pad}{text}{pad}{ticks}")
}
/// `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));
format!("{ticks}\n{text}\n{ticks}")
}
fn longest_run(text: &str) -> usize {
let mut longest = 0;
let mut current = 0;
for c in text.chars() {
current = if c == '`' { current + 1 } else { 0 };
longest = longest.max(current);
}
longest
}
/// `2026-09-24 14:47 UTC`.
fn minute(at: Timestamp) -> String {
let rfc = at.to_rfc3339();
format!(
"{} {} UTC",
rfc.get(..10).unwrap_or_default(),
rfc.get(11..16).unwrap_or_default()
)
}
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
match serde_json::to_value(value) {
Ok(serde_json::Value::String(s)) => s,
_ => format!("{value:?}"),
}
}
fn reply_line(id: u64) -> String {
format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")
}
/// 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<char> = args.chars().collect();
let parts: Vec<String> = 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<String> = 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 {
match ended {
Ended::Runs { by } => format!("✅ Approved by {by}: the call runs."),
Ended::Denied { by, reason } => {
format!("Approved by {by}, but denied on the re-check: {}.", name(reason))
}
Ended::Refused { by, reason: Some(reason) } => {
format!("❌ Refused by {by}: {}.", code(&escape_json_text(reason)))
}
Ended::Refused { by, reason: None } => {
format!("❌ Refused by {by}: no reason given.")
}
Ended::NoLonger => {
"No longer pending (answered elsewhere, expired, or its turn ended).".to_string()
}
Ended::Restarted => {
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
.to_string()
}
}
}
/// 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))
}