Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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);
    }
}