Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
158 lines
5.7 KiB
Rust
158 lines
5.7 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);`, 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<T: serde::Serialize + std::fmt::Debug>(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} <reason>`.")`.
|
|
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<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 {
|
|
// 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!()
|
|
}
|