crates/gunnar-ui-web/src/blame.rs
//! **What the blame view decides**, on the host side of the wasm boundary.
//!
//! The shape the blame endpoint answers with, and the one sentence the page has
//! to print when that answer was cut short. Here rather than in
//! [`crate::pages::blame`] for the reason [`crate::room`] and [`crate::mirror`]
//! are here: the page needs a DOM and this does not, and a rule that only ever
//! ran inside a browser is a rule with no test at all.
//!
//! The rule in question is not cosmetic. `api::blame` caps one request at
//! `MAX_VERIFIED_COMMITS` chain walks — the file is the caller's to name, so
//! the number of walks was the caller's to choose — and every line whose commit
//! it did not reach keeps `unproven`, which the page renders as the badge
//! **"unsigned"**. That is the safe default on the server and a falsehood on
//! the page: after a truncation, "unsigned" covers both *nobody signed this*
//! and *this console did not look*, which are opposite facts about the same
//! commit wearing the same word. In a view whose entire purpose is provenance,
//! saying nothing is the worst available answer.
use serde::Deserialize;
/// One line's attribution, as the blame endpoint answers it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct BlameLine {
pub number: usize,
pub commit: String,
pub author: String,
/// `proven` | `unproven` | `broken`.
pub signature: String,
pub signer: Option<String>,
pub text: String,
pub binary: bool,
}
/// A file's blame.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Blame {
pub rev: String,
pub path: String,
pub lines: Vec<BlameLine>,
pub commits: Vec<String>,
/// How many distinct commits the console actually proved.
#[serde(default)]
pub verified: usize,
/// **True when the console stopped proving before it ran out of commits.**
///
/// The server caps one blame at `MAX_VERIFIED_COMMITS` chain walks with a
/// five-second budget beside it, because the file is the caller's to name
/// and the walk count was therefore the caller's to choose. Lines from a
/// commit it did not reach keep `unproven` — the safe default.
///
/// Which is exactly why the page has to SAY so. `unproven` means "nobody
/// proved this" and the badge reads "unsigned"; after a truncation some of
/// those lines are instead "this console did not look", and the two are
/// opposite facts about the same commit wearing the same word. A page that
/// rendered the cap silently would be telling people their signed commits
/// are unsigned.
#[serde(default)]
pub truncated: bool,
/// The file is kept in Git LFS: the server sends no lines (they would be
/// the pointer's), and the page says where the file is instead.
#[serde(default)]
pub lfs: bool,
}
/// **What to say when the console stopped proving, and nothing when it did
/// not.**
///
/// A function rather than an expression inside the view, so the sentence can be
/// asserted without a browser — and so that "the page says something" and "the
/// page says the right numbers" are one test rather than a screenshot.
///
/// The sentence has to do a specific job. Every unproven line already renders
/// the badge "unsigned", and after a truncation some of those lines are not
/// unsigned at all — they are unexamined. Without this line the page states the
/// opposite of the truth about somebody's signed commits, confidently, in a
/// view whose entire purpose is provenance.
pub fn stopped_line(blame: &Blame) -> Option<String> {
blame.truncated.then(|| {
format!(
"This console proved the signatures of {} of {} distinct commits and stopped there. \
Lines from the rest read \"unsigned\" because nobody looked, not because a \
signature was missing — open one of those commits in Chain to settle it.",
blame.verified,
blame.commits.len(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn blame(truncated: bool, verified: usize, commits: usize) -> Blame {
Blame {
rev: "HEAD".to_owned(),
path: "src/lib.rs".to_owned(),
lines: Vec::new(),
commits: (0..commits).map(|n| format!("{n:040}")).collect(),
verified,
truncated,
lfs: false,
}
}
/// ★ **A blame that was cut short says so, in the numbers.**
///
/// `api::blame` caps one request at `MAX_VERIFIED_COMMITS` chain walks —
/// the file is the caller's to name, so the walk count was the caller's to
/// choose — and the lines of a commit it did not reach keep `unproven`,
/// which this page renders as the badge "unsigned".
///
/// That is the safe default on the server and a LIE on the page: after a
/// truncation, "unsigned" covers both "nobody signed this" and "this
/// console did not look", and they are opposite facts about the same commit
/// wearing the same word. In a view whose whole purpose is provenance that
/// is the worst possible silence.
///
/// Red before: `Blame` had neither field, so there was nothing to say it
/// with and `stopped_line` did not exist.
#[test]
fn a_truncated_blame_says_how_far_it_got() {
let said = stopped_line(&blame(true, 64, 500)).expect("a truncated blame says nothing");
assert!(said.contains("64 of 500"), "{said}");
assert!(
said.contains("unsigned"),
"the line has to name the word the badges show, or a reader cannot connect \
the two: {said}"
);
assert_eq!(
stopped_line(&blame(false, 3, 3)),
None,
"an ordinary blame grew a warning it did not earn"
);
}
/// ★ **The two fields are read off the wire under the names the server
/// sends.**
///
/// `#[serde(default)]` is what keeps an older console from failing on a
/// newer answer — and it is also what would silently swallow a renamed
/// field forever, leaving `truncated` permanently false and the page
/// permanently silent. So the JSON here is spelled the way
/// `api::blame::Blame` serialises it.
#[test]
fn the_truncation_fields_are_read_under_the_names_the_server_sends() {
let answer = r#"{
"rev": "HEAD",
"path": "src/lib.rs",
"lines": [],
"commits": ["a", "b"],
"verified": 1,
"truncated": true
}"#;
let parsed: Blame = serde_json::from_str(answer).expect("the server's own shape");
assert!(parsed.truncated, "`truncated` did not arrive");
assert_eq!(parsed.verified, 1, "`verified` did not arrive");
// And an answer from a console one release older still parses, which is
// what the defaults are for.
let older = r#"{"rev":"HEAD","path":"p","lines":[],"commits":[]}"#;
let parsed: Blame = serde_json::from_str(older).expect("an older answer");
assert!(!parsed.truncated);
}
}