Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Remove dependency `radicle-surf`
Draft fintohaps opened 3 months ago

The main use of radicle-surf in the radicle-cli crate is for producing diffs. One use of diffs is in the review builder logic, which is obsolete. The other use is in producing diffs for the rad id proposal changes.

If the review code is removed and git-diff-tree is used for rad id, then the dependency on radicle-surf can be removed.

While removing rad patch review [--patch | --delete] can be seen as a non-backwards compatible change, the code is obsolete – so this is deemed safe.

Using git-diff-tree in place of a heartwood defined diff format comes with the consquence of displaying temporary tree and path information, e.g.:

diff --git a/radicle.json b/radicle.json
index 600d27b..b30194b 100644
--- a/radicle.json
+++ b/radicle.json

Some massaging of this output could be made, but that needs to be decided if it is worth it.

9 files changed +28 -3172 02318f19 3ae269d2
modified Cargo.lock
@@ -615,7 +615,7 @@ dependencies = [
 "document-features",
 "mio 1.0.4",
 "parking_lot",
-
 "rustix 1.0.7",
+
 "rustix",
 "signal-hook",
 "signal-hook-mio",
 "winapi",
@@ -1011,18 +1011,6 @@ dependencies = [
]

[[package]]
-
name = "filetime"
-
version = "0.2.23"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
-
dependencies = [
-
 "cfg-if",
-
 "libc",
-
 "redox_syscall 0.4.1",
-
 "windows-sys 0.52.0",
-
]
-

-
[[package]]
name = "flate2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1123,16 +1111,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"

[[package]]
-
name = "git-ref-format"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ed6913a77cee9e231cab93577c9a5eea84a1344ab39294d91dc075b3c24499d0"
-
dependencies = [
-
 "git-ref-format-core",
-
 "git-ref-format-macro",
-
]
-

-
[[package]]
name = "git-ref-format-core"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1144,18 +1122,6 @@ dependencies = [
]

[[package]]
-
name = "git-ref-format-macro"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4e730f09c82961c28f5465b83da0aa5c2716156ce57da33a1fa51bbd560aa5f7"
-
dependencies = [
-
 "git-ref-format-core",
-
 "proc-macro-error2",
-
 "quote",
-
 "syn 2.0.106",
-
]
-

-
[[package]]
name = "git2"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1452,7 +1418,7 @@ dependencies = [
 "gix-command",
 "gix-config-value",
 "parking_lot",
-
 "rustix 1.0.7",
+
 "rustix",
 "thiserror 2.0.17",
]

@@ -2146,12 +2112,6 @@ dependencies = [

[[package]]
name = "linux-raw-sys"
-
version = "0.4.13"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
-

-
[[package]]
-
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
@@ -2515,7 +2475,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
 "cfg-if",
 "libc",
-
 "redox_syscall 0.5.18",
+
 "redox_syscall",
 "smallvec",
 "windows-link",
]
@@ -2685,28 +2645,6 @@ dependencies = [
]

[[package]]
-
name = "proc-macro-error-attr2"
-
version = "2.0.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
-
dependencies = [
-
 "proc-macro2",
-
 "quote",
-
]
-

-
[[package]]
-
name = "proc-macro-error2"
-
version = "2.0.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
-
dependencies = [
-
 "proc-macro-error-attr2",
-
 "proc-macro2",
-
 "quote",
-
 "syn 2.0.106",
-
]
-

-
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2833,7 +2771,6 @@ dependencies = [
 "radicle-git-ref-format",
 "radicle-localtime",
 "radicle-node",
-
 "radicle-surf",
 "radicle-term",
 "schemars",
 "serde",
@@ -2945,20 +2882,6 @@ dependencies = [
]

[[package]]
-
name = "radicle-git-ext"
-
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "71a5fbca2ee3fc61a6b467e0b85da7c092421afc2538feb0023ad6792d6e39d0"
-
dependencies = [
-
 "git-ref-format",
-
 "git2",
-
 "percent-encoding",
-
 "radicle-std-ext",
-
 "serde",
-
 "thiserror 1.0.69",
-
]
-

-
[[package]]
name = "radicle-git-metadata"
version = "0.1.0"
dependencies = [
@@ -3098,31 +3021,6 @@ dependencies = [
]

[[package]]
-
name = "radicle-std-ext"
-
version = "0.2.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fb935931bdd2a2966f3b584f3031d9d54ec0713ddbc563a0193d54e62a88ec73"
-

-
[[package]]
-
name = "radicle-surf"
-
version = "0.26.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4c814514d0bf56fbec811099eaa14da1349639b04b8317746c9cd9e6b0f02196"
-
dependencies = [
-
 "anyhow",
-
 "base64 0.21.7",
-
 "flate2",
-
 "git2",
-
 "log",
-
 "nonempty",
-
 "radicle-git-ext",
-
 "radicle-std-ext",
-
 "tar",
-
 "thiserror 1.0.69",
-
 "url",
-
]
-

-
[[package]]
name = "radicle-systemd"
version = "0.11.0"
dependencies = [
@@ -3183,15 +3081,6 @@ dependencies = [

[[package]]
name = "redox_syscall"
-
version = "0.4.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
-
dependencies = [
-
 "bitflags 1.3.2",
-
]
-

-
[[package]]
-
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
@@ -3316,19 +3205,6 @@ checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"

[[package]]
name = "rustix"
-
version = "0.38.34"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
-
dependencies = [
-
 "bitflags 2.9.1",
-
 "errno",
-
 "libc",
-
 "linux-raw-sys 0.4.13",
-
 "windows-sys 0.52.0",
-
]
-

-
[[package]]
-
name = "rustix"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
@@ -3336,7 +3212,7 @@ dependencies = [
 "bitflags 2.9.1",
 "errno",
 "libc",
-
 "linux-raw-sys 0.9.4",
+
 "linux-raw-sys",
 "windows-sys 0.59.0",
]

@@ -3961,18 +3837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7266304d24ca5a4b230545fc558c80e18bd3e1d2eb1be149b6bcd04398d3e79c"
dependencies = [
 "log",
-
 "rustix 1.0.7",
-
]
-

-
[[package]]
-
name = "tar"
-
version = "0.4.40"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb"
-
dependencies = [
-
 "filetime",
-
 "libc",
-
 "xattr",
+
 "rustix",
]

[[package]]
@@ -3984,7 +3849,7 @@ dependencies = [
 "fastrand",
 "getrandom 0.3.3",
 "once_cell",
-
 "rustix 1.0.7",
+
 "rustix",
 "windows-sys 0.60.2",
]

@@ -5011,17 +4876,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"

[[package]]
-
name = "xattr"
-
version = "1.3.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
-
dependencies = [
-
 "libc",
-
 "linux-raw-sys 0.4.13",
-
 "rustix 0.38.34",
-
]
-

-
[[package]]
name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified crates/radicle-cli/Cargo.toml
@@ -28,7 +28,6 @@ radicle-cob = { workspace = true }
radicle-crypto = { workspace = true }
radicle-git-ref-format = { workspace = true, features = ["macro"] }
radicle-localtime = { workspace = true }
-
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
modified crates/radicle-cli/src/commands/id.rs
@@ -12,10 +12,8 @@ use radicle::node::device::Device;
use radicle::node::NodeId;
use radicle::storage::{ReadStorage as _, WriteRepository};
use radicle::{cob, crypto, Profile};
-
use radicle_surf::diff::Diff;
use radicle_term::Element;

-
use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
use crate::terminal::args::Error;
@@ -445,14 +443,14 @@ fn print_diff(
) -> anyhow::Result<()> {
    let previous = if let Some(previous) = previous {
        let previous = Doc::load_at(*previous, repo)?;
-
        let previous = serde_json::to_string_pretty(&previous.doc)?;
+
        let previous = format!("{}\n", serde_json::to_string_pretty(&previous.doc)?);

        Some(previous)
    } else {
        None
    };
    let current = Doc::load_at(*current, repo)?;
-
    let current = serde_json::to_string_pretty(&current.doc)?;
+
    let current = format!("{}\n", serde_json::to_string_pretty(&current.doc)?);

    let tmp = tempfile::tempdir()?;
    let repo = radicle::git::raw::Repository::init_opts(
@@ -469,19 +467,22 @@ fn print_diff(
        None
    };
    let current = radicle::git::write_tree(&doc::PATH, current.as_bytes(), &repo)?;
-
    let mut opts = radicle::git::raw::DiffOptions::new();
-
    opts.context_lines(u32::MAX);

-
    let diff = repo.diff_tree_to_tree(previous.as_ref(), Some(&current), Some(&mut opts))?;
-
    let diff = Diff::try_from(diff)?;
-

-
    if let Some(modified) = diff.modified().next() {
-
        let diff = modified.diff.to_unified_string()?;
-
        print!("{diff}");
-
    } else {
-
        term::print(term::format::italic("No changes."));
+
    {
+
        let mut diff = std::process::Command::new("git");
+
        diff.args(["-C".to_string(), repo.path().display().to_string()]);
+
        diff.arg("diff-tree");
+
        if let Some(previous) = previous {
+
            diff.arg(format!("{}", previous.id()));
+
        }
+
        diff.arg(format!("{}", current.id()));
+
        diff.arg(format!("-U{}", u16::MAX));
+
        // diff.arg(format!("-- {}", doc::PATH.display().to_string()));
+
        // eprintln!("{:?}", diff);
+
        let mut child = diff.spawn()?;
+
        let exit_status = child.wait()?;
+
        std::process::exit(exit_status.code().unwrap_or(1));
    }
-
    Ok(())
}

fn print_delegate_verification_error(err: &update::error::DelegateVerification) {
modified crates/radicle-cli/src/commands/patch/review.rs
@@ -1,9 +1,6 @@
-
mod builder;
-

use anyhow::{anyhow, Context};

use radicle::cob::patch::{PatchId, RevisionId, Verdict};
-
use radicle::git;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -74,7 +71,7 @@ pub fn run(
        .get_mut(&patch_id)
        .context(format!("couldn't find patch {patch_id} locally"))?;

-
    let (revision_id, revision) = match revision_id {
+
    let (revision_id, _revision) = match revision_id {
        Some(id) => (
            id,
            patch
@@ -88,20 +85,12 @@ pub fn run(
    match options.op {
        Operation::Review(ReviewOptions {
            by_hunk,
-
            unified,
-
            hunk,
-
            verdict,
+
            unified: _,
+
            hunk: _,
+
            verdict: _,
        }) if by_hunk => {
            crate::warning::obsolete("rad patch review --patch");
-
            let mut opts = git::raw::DiffOptions::new();
-
            opts.patience(true)
-
                .minimal(true)
-
                .context_lines(unified as u32);
-

-
            builder::ReviewBuilder::new(patch_id, repository)
-
                .hunk(hunk)
-
                .verdict(verdict)
-
                .run(revision, &mut opts, &signer)?;
+
            anyhow::bail!("`rad patch review --patch` has been removed")
        }
        Operation::Review(ReviewOptions { verdict, .. }) => {
            let message = options.message.get(REVIEW_HELP_MSG)?;
@@ -135,14 +124,7 @@ pub fn run(
        }
        Operation::Delete => {
            crate::warning::obsolete("rad patch review --delete");
-
            let name = git::refs::storage::draft::review(profile.id(), &patch_id);
-

-
            match repository.backend.find_reference(&name) {
-
                Ok(mut r) => r.delete()?,
-
                Err(e) => {
-
                    anyhow::bail!("Couldn't delete review reference '{name}': {e}");
-
                }
-
            }
+
            anyhow::bail!("`rad patch review --delete` has been removed")
        }
    }

deleted crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -1,1280 +0,0 @@
-
//! Review builder.
-
//!
-
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
-
//! The interaction and output is modeled around `git add -p`.
-
//!
-
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
-
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
-
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
-
//!
-
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
-
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
-
//! been fully reviewed.
-
//!
-
use std::collections::VecDeque;
-
use std::fmt::Write as _;
-
use std::ops::{Deref, Not, Range};
-
use std::path::{Path, PathBuf};
-
use std::str::FromStr;
-
use std::{fmt, io};
-

-
use radicle::cob;
-
use radicle::cob::patch::{PatchId, Revision, Verdict};
-
use radicle::cob::{CodeLocation, CodeRange};
-
use radicle::crypto;
-
use radicle::git;
-
use radicle::git::Oid;
-
use radicle::node::device::Device;
-
use radicle::prelude::*;
-
use radicle::storage::git::{cob::DraftStore, Repository};
-
use radicle_surf::diff::*;
-
use radicle_term::{Element, VStack};
-

-
use crate::git::pretty_diff::ToPretty;
-
use crate::git::pretty_diff::{Blob, Blobs, Repo};
-
use crate::git::unified_diff::{self, FileHeader};
-
use crate::git::unified_diff::{Encode, HunkHeader};
-
use crate::terminal as term;
-
use crate::terminal::highlight::Highlighter;
-

-
/// Help message shown to user.
-
const HELP: &str = "\
-
y - accept this hunk
-
n - ignore this hunk
-
c - comment on this hunk
-
j - leave this hunk undecided, see next hunk
-
k - leave this hunk undecided, see previous hunk
-
s - split the current hunk into smaller hunks
-
q - quit; do not accept this hunk nor any of the remaining ones
-
? - print help";
-

-
/// A terminal or file where the review UI output can be written to.
-
trait PromptWriter: io::Write {
-
    /// Is the writer a terminal?
-
    fn is_terminal(&self) -> bool;
-
}
-

-
impl PromptWriter for Box<dyn PromptWriter> {
-
    fn is_terminal(&self) -> bool {
-
        self.deref().is_terminal()
-
    }
-
}
-

-
impl<T: io::Write + io::IsTerminal> PromptWriter for T {
-
    fn is_terminal(&self) -> bool {
-
        <Self as io::IsTerminal>::is_terminal(self)
-
    }
-
}
-

-
/// The actions that a user can carry out on a review item.
-
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
-
pub enum ReviewAction {
-
    Accept,
-
    Ignore,
-
    Comment,
-
    Split,
-
    Next,
-
    Previous,
-
    Help,
-
    Quit,
-
}
-

-
impl ReviewAction {
-
    /// Ask the user what action to take.
-
    fn prompt(
-
        mut input: impl io::BufRead,
-
        mut output: impl io::Write,
-
        prompt: impl fmt::Display,
-
    ) -> io::Result<Option<Self>> {
-
        write!(&mut output, "{prompt} ")?;
-

-
        let mut s = String::new();
-
        input.read_line(&mut s)?;
-

-
        if s.trim().is_empty() {
-
            return Ok(None);
-
        }
-
        Self::from_str(s.trim())
-
            .map(Some)
-
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
-
    }
-
}
-

-
impl std::fmt::Display for ReviewAction {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Self::Accept => write!(f, "y"),
-
            Self::Ignore => write!(f, "n"),
-
            Self::Comment => write!(f, "c"),
-
            Self::Split => write!(f, "s"),
-
            Self::Next => write!(f, "j"),
-
            Self::Previous => write!(f, "k"),
-
            Self::Help => write!(f, "?"),
-
            Self::Quit => write!(f, "q"),
-
        }
-
    }
-
}
-

-
impl FromStr for ReviewAction {
-
    type Err = io::Error;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        match s {
-
            "y" => Ok(Self::Accept),
-
            "n" => Ok(Self::Ignore),
-
            "c" => Ok(Self::Comment),
-
            "s" => Ok(Self::Split),
-
            "j" => Ok(Self::Next),
-
            "k" => Ok(Self::Previous),
-
            "?" => Ok(Self::Help),
-
            "q" => Ok(Self::Quit),
-
            _ => Err(io::Error::new(
-
                io::ErrorKind::InvalidInput,
-
                format!("invalid action '{s}'"),
-
            )),
-
        }
-
    }
-
}
-

-
/// A single review item. Can be a hunk or eg. a file move.
-
/// Files are usually split into multiple review items.
-
#[derive(Debug)]
-
pub enum ReviewItem {
-
    FileAdded {
-
        path: PathBuf,
-
        header: FileHeader,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileDeleted {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileModified {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileMoved {
-
        moved: Moved,
-
    },
-
    FileCopied {
-
        copied: Copied,
-
    },
-
    FileEofChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        eof: EofNewLine,
-
    },
-
    FileModeChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
    },
-
}
-

-
impl ReviewItem {
-
    fn hunk(&self) -> Option<&Hunk<Modification>> {
-
        match self {
-
            Self::FileAdded { hunk, .. } => hunk.as_ref(),
-
            Self::FileDeleted { hunk, .. } => hunk.as_ref(),
-
            Self::FileModified { hunk, .. } => hunk.as_ref(),
-
            _ => None,
-
        }
-
    }
-

-
    fn hunk_header(&self) -> Option<HunkHeader> {
-
        self.hunk().and_then(|h| HunkHeader::try_from(h).ok())
-
    }
-

-
    fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
-
        match self {
-
            Self::FileAdded { path, new, .. } => (None, Some((path, Oid::from(*new.oid)))),
-
            Self::FileDeleted { path, old, .. } => (Some((path, Oid::from(*old.oid))), None),
-
            Self::FileMoved { moved } => (
-
                Some((&moved.old_path, Oid::from(*moved.old.oid))),
-
                Some((&moved.new_path, Oid::from(*moved.new.oid))),
-
            ),
-
            Self::FileCopied { copied } => (
-
                Some((&copied.old_path, Oid::from(*copied.old.oid))),
-
                Some((&copied.new_path, Oid::from(*copied.new.oid))),
-
            ),
-
            Self::FileModified { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
-
            ),
-
            Self::FileEofChanged { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
-
            ),
-
            Self::FileModeChanged { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
-
            ),
-
        }
-
    }
-

-
    fn file_header(&self) -> FileHeader {
-
        match self {
-
            Self::FileAdded { header, .. } => header.clone(),
-
            Self::FileDeleted { header, .. } => header.clone(),
-
            Self::FileMoved { moved } => FileHeader::Moved {
-
                old_path: moved.old_path.clone(),
-
                new_path: moved.new_path.clone(),
-
            },
-
            Self::FileCopied { copied } => FileHeader::Copied {
-
                old_path: copied.old_path.clone(),
-
                new_path: copied.new_path.clone(),
-
            },
-
            Self::FileModified { header, .. } => header.clone(),
-
            Self::FileEofChanged { header, .. } => header.clone(),
-
            Self::FileModeChanged { header, .. } => header.clone(),
-
        }
-
    }
-

-
    fn blobs<R: Repo>(&self, repo: &R) -> Blobs<(PathBuf, Blob)> {
-
        let (old, new) = self.paths();
-
        Blobs::from_paths(old, new, repo)
-
    }
-

-
    fn pretty<R: Repo>(&self, repo: &R) -> Box<dyn Element> {
-
        let mut hi = Highlighter::default();
-
        let blobs = self.blobs(repo);
-
        let highlighted = blobs.highlight(&mut hi);
-
        let header = self.file_header();
-

-
        match self {
-
            Self::FileMoved { moved } => moved.pretty(&mut hi, &header, repo),
-
            Self::FileCopied { copied } => copied.pretty(&mut hi, &header, repo),
-
            Self::FileModified { hunk, .. }
-
            | Self::FileAdded { hunk, .. }
-
            | Self::FileDeleted { hunk, .. } => {
-
                let header = header.pretty(&mut hi, &None, repo);
-
                let vstack = term::VStack::default()
-
                    .border(Some(term::colors::FAINT))
-
                    .padding(1)
-
                    .child(header);
-

-
                if let Some(hunk) = hunk {
-
                    let hunk = hunk.pretty(&mut hi, &highlighted, repo);
-
                    if !hunk.is_empty() {
-
                        return vstack.divider().merge(hunk).boxed();
-
                    }
-
                }
-
                vstack
-
            }
-
            Self::FileEofChanged { eof, .. } => match eof {
-
                EofNewLine::NewMissing => {
-
                    VStack::default().child(term::Label::new("`\\n` missing at end-of-file"))
-
                }
-
                EofNewLine::OldMissing => {
-
                    VStack::default().child(term::Label::new("`\\n` added at end-of-file"))
-
                }
-
                _ => VStack::default(),
-
            },
-
            Self::FileModeChanged { .. } => VStack::default(),
-
        }
-
        .boxed()
-
    }
-
}
-

-
/// Queue of items (usually hunks) left to review.
-
#[derive(Default)]
-
pub struct ReviewQueue {
-
    /// Hunks left to review.
-
    queue: VecDeque<(usize, ReviewItem)>,
-
}
-

-
impl ReviewQueue {
-
    /// Add a file to the queue.
-
    /// Mostly splits files into individual review items (eg. hunks) to review.
-
    fn add_file(&mut self, file: FileDiff) {
-
        let header = FileHeader::from(&file);
-

-
        match file {
-
            FileDiff::Moved(moved) => {
-
                self.add_item(ReviewItem::FileMoved { moved });
-
            }
-
            FileDiff::Copied(copied) => {
-
                self.add_item(ReviewItem::FileCopied { copied });
-
            }
-
            FileDiff::Added(a) => {
-
                self.add_item(ReviewItem::FileAdded {
-
                    path: a.path,
-
                    header: header.clone(),
-
                    new: a.new,
-
                    hunk: if let DiffContent::Plain {
-
                        hunks: Hunks(mut hs),
-
                        ..
-
                    } = a.diff
-
                    {
-
                        hs.pop()
-
                    } else {
-
                        None
-
                    },
-
                });
-
            }
-
            FileDiff::Deleted(d) => {
-
                self.add_item(ReviewItem::FileDeleted {
-
                    path: d.path,
-
                    header: header.clone(),
-
                    old: d.old,
-
                    hunk: if let DiffContent::Plain {
-
                        hunks: Hunks(mut hs),
-
                        ..
-
                    } = d.diff
-
                    {
-
                        hs.pop()
-
                    } else {
-
                        None
-
                    },
-
                });
-
            }
-
            FileDiff::Modified(m) => {
-
                if m.old.mode != m.new.mode {
-
                    self.add_item(ReviewItem::FileModeChanged {
-
                        path: m.path.clone(),
-
                        header: header.clone(),
-
                        old: m.old.clone(),
-
                        new: m.new.clone(),
-
                    });
-
                }
-
                match m.diff {
-
                    DiffContent::Empty => {
-
                        // Likely a file mode change, which is handled above.
-
                    }
-
                    DiffContent::Binary => {
-
                        self.add_item(ReviewItem::FileModified {
-
                            path: m.path.clone(),
-
                            header: header.clone(),
-
                            old: m.old.clone(),
-
                            new: m.new.clone(),
-
                            hunk: None,
-
                        });
-
                    }
-
                    DiffContent::Plain {
-
                        hunks: Hunks(hunks),
-
                        eof,
-
                        ..
-
                    } => {
-
                        for hunk in hunks {
-
                            self.add_item(ReviewItem::FileModified {
-
                                path: m.path.clone(),
-
                                header: header.clone(),
-
                                old: m.old.clone(),
-
                                new: m.new.clone(),
-
                                hunk: Some(hunk),
-
                            });
-
                        }
-
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
-
                            self.add_item(ReviewItem::FileEofChanged {
-
                                path: m.path.clone(),
-
                                header: header.clone(),
-
                                old: m.old.clone(),
-
                                new: m.new.clone(),
-
                                eof,
-
                            })
-
                        }
-
                    }
-
                }
-
            }
-
        }
-
    }
-

-
    fn add_item(&mut self, item: ReviewItem) {
-
        self.queue.push_back((self.queue.len(), item));
-
    }
-
}
-

-
impl From<Diff> for ReviewQueue {
-
    fn from(diff: Diff) -> Self {
-
        let mut queue = Self::default();
-
        for file in diff.into_files() {
-
            queue.add_file(file);
-
        }
-
        queue
-
    }
-
}
-

-
impl std::ops::Deref for ReviewQueue {
-
    type Target = VecDeque<(usize, ReviewItem)>;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.queue
-
    }
-
}
-

-
impl std::ops::DerefMut for ReviewQueue {
-
    fn deref_mut(&mut self) -> &mut Self::Target {
-
        &mut self.queue
-
    }
-
}
-

-
impl Iterator for ReviewQueue {
-
    type Item = (usize, ReviewItem);
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        self.queue.pop_front()
-
    }
-
}
-

-
/// Builds a review for a single file.
-
/// Adjusts line deltas when a hunk is ignored.
-
pub struct FileReviewBuilder {
-
    header: FileHeader,
-
    delta: i32,
-
}
-

-
impl FileReviewBuilder {
-
    fn new(item: &ReviewItem) -> Self {
-
        Self {
-
            header: item.file_header(),
-
            delta: 0,
-
        }
-
    }
-

-
    fn set_item(&mut self, item: &ReviewItem) -> &mut Self {
-
        let header = item.file_header();
-
        if self.header != header {
-
            self.header = header;
-
            self.delta = 0;
-
        }
-
        self
-
    }
-

-
    fn ignore_item(&mut self, item: &ReviewItem) {
-
        if let Some(h) = item.hunk_header() {
-
            self.delta += h.new_size as i32 - h.old_size as i32;
-
        }
-
    }
-

-
    fn item_diff(&mut self, item: ReviewItem) -> Result<git::raw::Diff<'_>, Error> {
-
        let mut buf = Vec::new();
-
        let mut writer = unified_diff::Writer::new(&mut buf);
-
        writer.encode(&self.header)?;
-

-
        if let (Some(h), Some(mut header)) = (item.hunk(), item.hunk_header()) {
-
            header.old_line_no -= self.delta as u32;
-
            header.new_line_no -= self.delta as u32;
-

-
            let h = Hunk {
-
                header: header.to_unified_string()?.as_bytes().to_owned().into(),
-
                lines: h.lines.clone(),
-
                old: h.old.clone(),
-
                new: h.new.clone(),
-
            };
-
            writer.encode(&h)?;
-
        }
-
        drop(writer);
-

-
        git::raw::Diff::from_buffer(&buf).map_err(Error::from)
-
    }
-
}
-

-
/// Represents the reviewer's brain, ie. what they have seen or not seen in terms
-
/// of changes introduced by a patch.
-
pub struct Brain<'a> {
-
    /// Where the review draft is being stored.
-
    refname: git::fmt::Namespaced<'a>,
-
    /// The commit pointed to by the ref.
-
    head: git::raw::Commit<'a>,
-
    /// The tree of accepted changes pointed to by the head commit.
-
    accepted: git::raw::Tree<'a>,
-
}
-

-
impl<'a> Brain<'a> {
-
    /// Create a new brain in the repository.
-
    fn new(
-
        patch: PatchId,
-
        remote: &NodeId,
-
        base: git::raw::Commit,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<Self, git::raw::Error> {
-
        let refname = Self::refname(&patch, remote);
-
        let author = repo.signature()?;
-
        let oid = repo.commit(
-
            Some(refname.as_str()),
-
            &author,
-
            &author,
-
            &format!("Review for {patch}"),
-
            &base.tree()?,
-
            // TODO: Verify this is necessary, shouldn't matter.
-
            &[&base],
-
        )?;
-
        let head = repo.find_commit(oid)?;
-
        let tree = head.tree()?;
-

-
        Ok(Self {
-
            refname,
-
            head,
-
            accepted: tree,
-
        })
-
    }
-

-
    /// Return the content identifier of this brain. This represents the state of the
-
    /// accepted hunks, ie. the git tree.
-
    fn cid(&self) -> Oid {
-
        self.accepted.id().into()
-
    }
-

-
    /// Load an existing brain from the repository.
-
    fn load(
-
        patch: PatchId,
-
        remote: &NodeId,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<Self, git::raw::Error> {
-
        // TODO: Validate this leads to correct UX for potentially abandoned drafts on
-
        // past revisions.
-
        let refname = Self::refname(&patch, remote);
-
        let head = repo.find_reference(&refname)?.peel_to_commit()?;
-
        let tree = head.tree()?;
-

-
        Ok(Self {
-
            refname,
-
            head,
-
            accepted: tree,
-
        })
-
    }
-

-
    /// Accept changes to the brain.
-
    fn accept(
-
        &mut self,
-
        diff: git::raw::Diff,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<(), git::raw::Error> {
-
        let mut index = repo.apply_to_tree(&self.accepted, &diff, None)?;
-
        let accepted = index.write_tree_to(repo)?;
-
        self.accepted = repo.find_tree(accepted)?;
-

-
        // Update review with new brain.
-
        let head = self.head.amend(
-
            Some(&self.refname),
-
            None,
-
            None,
-
            None,
-
            None,
-
            Some(&self.accepted),
-
        )?;
-
        self.head = repo.find_commit(head)?;
-

-
        Ok(())
-
    }
-

-
    /// Get the brain's refname given the patch and remote.
-
    fn refname(patch: &PatchId, remote: &NodeId) -> git::fmt::Namespaced<'a> {
-
        git::refs::storage::draft::review(remote, patch)
-
    }
-
}
-

-
/// Builds a patch review interactively, across multiple files.
-
pub struct ReviewBuilder<'a> {
-
    /// Patch being reviewed.
-
    patch_id: PatchId,
-
    /// Stored copy of repository.
-
    repo: &'a Repository,
-
    /// Single hunk review.
-
    hunk: Option<usize>,
-
    /// Verdict for review items.
-
    verdict: Option<Verdict>,
-
}
-

-
impl<'a> ReviewBuilder<'a> {
-
    /// Create a new review builder.
-
    pub fn new(patch_id: PatchId, repo: &'a Repository) -> Self {
-
        Self {
-
            patch_id,
-
            repo,
-
            hunk: None,
-
            verdict: None,
-
        }
-
    }
-

-
    /// Review a single hunk. Set to `None` to review all hunks.
-
    pub fn hunk(mut self, hunk: Option<usize>) -> Self {
-
        self.hunk = hunk;
-
        self
-
    }
-

-
    /// Give this verdict to all review items. Set to `None` to not give a verdict.
-
    pub fn verdict(mut self, verdict: Option<Verdict>) -> Self {
-
        self.verdict = verdict;
-
        self
-
    }
-

-
    /// Run the review builder for the given revision.
-
    pub fn run<G>(
-
        self,
-
        revision: &Revision,
-
        opts: &mut git::raw::DiffOptions,
-
        signer: &Device<G>,
-
    ) -> anyhow::Result<()>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
    {
-
        let repo = self.repo.raw();
-
        let base = repo.find_commit((*revision.base()).into())?;
-
        let patch_id = self.patch_id;
-
        let tree = {
-
            let commit = repo.find_commit(revision.head().into())?;
-
            commit.tree()?
-
        };
-

-
        let stdout = io::stdout().lock();
-
        let mut stdin = io::stdin().lock();
-
        let mut writer: Box<dyn PromptWriter> = if self.hunk.is_some() || !stdout.is_terminal() {
-
            Box::new(stdout)
-
        } else {
-
            Box::new(io::stderr().lock())
-
        };
-
        let mut brain = if let Ok(b) = Brain::load(self.patch_id, signer.public_key(), repo) {
-
            term::success!(
-
                "Loaded existing review {} for patch {}",
-
                term::format::secondary(term::format::parens(term::format::oid(b.head.id()))),
-
                term::format::tertiary(&patch_id)
-
            );
-
            b
-
        } else {
-
            Brain::new(self.patch_id, signer.public_key(), base, repo)?
-
        };
-
        let diff = self.diff(&brain.accepted, &tree, repo, opts)?;
-
        let drafts = DraftStore::new(self.repo, *signer.public_key());
-
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
-
        let mut patch = patches.get_mut(&patch_id)?;
-
        let mut queue = ReviewQueue::from(diff);
-

-
        if queue.is_empty() {
-
            term::success!("All hunks have been reviewed");
-
            return Ok(());
-
        }
-

-
        let review = if let Some(r) = revision.review_by(signer.public_key()) {
-
            r.id()
-
        } else {
-
            patch.review(
-
                revision.id(),
-
                // This is amended before the review is finalized, if all hunks are
-
                // accepted. We can't set this to `None`, as that will be invalid without
-
                // a review summary.
-
                Some(Verdict::Reject),
-
                None,
-
                vec![],
-
                signer,
-
            )?
-
        };
-

-
        // File review for the current file. Starts out as `None` and is set on the first hunk.
-
        // Keeps track of deltas for hunk offsets.
-
        let mut file: Option<FileReviewBuilder> = None;
-
        let total = queue.len();
-

-
        while let Some((ix, item)) = queue.next() {
-
            if let Some(hunk) = self.hunk {
-
                if hunk != ix + 1 {
-
                    continue;
-
                }
-
            }
-
            let progress = term::format::secondary(format!("({}/{total})", ix + 1));
-
            let file = match file.as_mut() {
-
                Some(fr) => fr.set_item(&item),
-
                None => file.insert(FileReviewBuilder::new(&item)),
-
            };
-
            term::element::write_to(
-
                &item.pretty(repo),
-
                &mut writer,
-
                term::Constraint::from_env().unwrap_or_default(),
-
            )?;
-

-
            // Prompts the user for action on the above hunk.
-
            match self.prompt(&mut stdin, &mut writer, progress) {
-
                // When a hunk is accepted, we convert it to unified diff format,
-
                // and apply it to the `brain`.
-
                Some(ReviewAction::Accept) => {
-
                    // Compute hunk diff and update brain by applying it.
-
                    let diff = file.item_diff(item)?;
-
                    brain.accept(diff, repo)?;
-

-
                    if self.hunk.is_some() {
-
                        term::success!("Updated brain to {}", brain.cid());
-
                    }
-
                }
-
                Some(ReviewAction::Ignore) => {
-
                    // Do nothing. Hunk will be reviewable again next time.
-
                    file.ignore_item(&item);
-
                }
-
                Some(ReviewAction::Comment) => {
-
                    let (old, new) = item.paths();
-
                    let path = old.or(new);
-

-
                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
-
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
-
                        let comments = builder.edit(hunk)?;
-

-
                        patch.transaction("Review comments", signer, |tx| {
-
                            for comment in comments {
-
                                tx.review_comment(
-
                                    review,
-
                                    comment.body,
-
                                    Some(comment.location),
-
                                    None,   // Not a reply.
-
                                    vec![], // No embeds.
-
                                )?;
-
                            }
-
                            Ok(())
-
                        })?;
-
                    } else {
-
                        eprintln!(
-
                            "{}",
-
                            term::format::tertiary(
-
                                "Commenting on binary blobs is not yet implemented"
-
                            )
-
                            .bold()
-
                        );
-
                        queue.push_front((ix, item));
-
                    }
-
                }
-
                Some(ReviewAction::Split) => {
-
                    eprintln!(
-
                        "{}",
-
                        term::format::tertiary("Splitting is not yet implemented").bold()
-
                    );
-
                    queue.push_front((ix, item));
-
                }
-
                Some(ReviewAction::Next) => {
-
                    queue.push_back((ix, item));
-
                }
-
                Some(ReviewAction::Previous) => {
-
                    queue.push_front((ix, item));
-

-
                    if let Some(e) = queue.pop_back() {
-
                        queue.push_front(e);
-
                    }
-
                }
-
                Some(ReviewAction::Quit) => {
-
                    break;
-
                }
-
                Some(ReviewAction::Help) => {
-
                    eprintln!("{}", term::format::tertiary(HELP).bold());
-
                    queue.push_front((ix, item));
-
                }
-
                None => {
-
                    eprintln!(
-
                        "{}",
-
                        term::format::secondary(format!(
-
                            "{} hunk(s) remaining to review",
-
                            queue.len() + 1
-
                        ))
-
                    );
-
                    queue.push_front((ix, item));
-
                }
-
            }
-
        }
-

-
        Ok(())
-
    }
-

-
    fn diff(
-
        &self,
-
        brain: &git::raw::Tree<'_>,
-
        tree: &git::raw::Tree<'_>,
-
        repo: &'a git::raw::Repository,
-
        opts: &mut git::raw::DiffOptions,
-
    ) -> Result<Diff, Error> {
-
        let mut find_opts = git::raw::DiffFindOptions::new();
-
        find_opts.exact_match_only(true);
-
        find_opts.all(true);
-
        find_opts.copies(false); // We don't support finding copies at the moment.
-

-
        let mut diff = repo.diff_tree_to_tree(Some(brain), Some(tree), Some(opts))?;
-
        diff.find_similar(Some(&mut find_opts))?;
-

-
        let diff = Diff::try_from(diff)?;
-

-
        Ok(diff)
-
    }
-

-
    fn prompt(
-
        &self,
-
        mut input: impl io::BufRead,
-
        output: &mut impl PromptWriter,
-
        progress: impl fmt::Display,
-
    ) -> Option<ReviewAction> {
-
        if let Some(v) = self.verdict {
-
            match v {
-
                Verdict::Accept => Some(ReviewAction::Accept),
-
                Verdict::Reject => Some(ReviewAction::Ignore),
-
            }
-
        } else if output.is_terminal() {
-
            let prompt = term::format::secondary("Accept this hunk? [y,n,c,j,k,q,?]").bold();
-

-
            ReviewAction::prompt(&mut input, output, format!("{progress} {prompt}"))
-
                .unwrap_or(Some(ReviewAction::Help))
-
        } else {
-
            Some(ReviewAction::Ignore)
-
        }
-
    }
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
struct ReviewComment {
-
    location: CodeLocation,
-
    body: String,
-
}
-

-
#[derive(thiserror::Error, Debug)]
-
enum Error {
-
    #[error(transparent)]
-
    Diff(#[from] unified_diff::Error),
-
    #[error(transparent)]
-
    Surf(#[from] radicle_surf::diff::git::error::Diff),
-
    #[error(transparent)]
-
    Io(#[from] io::Error),
-
    #[error(transparent)]
-
    Format(#[from] std::fmt::Error),
-
    #[error(transparent)]
-
    Git(#[from] git::raw::Error),
-
}
-

-
#[derive(Debug)]
-
struct CommentBuilder {
-
    commit: Oid,
-
    path: PathBuf,
-
    comments: Vec<ReviewComment>,
-
}
-

-
impl CommentBuilder {
-
    fn new(commit: Oid, path: PathBuf) -> Self {
-
        Self {
-
            commit,
-
            path,
-
            comments: Vec::new(),
-
        }
-
    }
-

-
    fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
-
        let mut input = String::new();
-
        for line in hunk.to_unified_string()?.lines() {
-
            writeln!(&mut input, "> {line}")?;
-
        }
-
        let output = term::Editor::comment()
-
            .extension("diff")
-
            .initial(input)?
-
            .edit()?;
-

-
        if let Some(output) = output {
-
            let header = HunkHeader::try_from(hunk)?;
-
            self.add_hunk(header, &output);
-
        }
-
        Ok(self.comments())
-
    }
-

-
    fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
-
        let lines = input.trim().lines().map(|l| l.trim());
-
        let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
-
        let (mut old_start, mut new_start) = (old_line, new_line);
-
        let mut comment = String::new();
-

-
        for line in lines {
-
            if line.starts_with('>') {
-
                if !comment.is_empty() {
-
                    self.add_comment(
-
                        &hunk,
-
                        &comment,
-
                        old_start..old_line - 1,
-
                        new_start..new_line - 1,
-
                    );
-

-
                    old_start = old_line - 1;
-
                    new_start = new_line - 1;
-

-
                    comment.clear();
-
                }
-
                match line.trim_start_matches('>').trim_start().chars().next() {
-
                    Some('-') => old_line += 1,
-
                    Some('+') => new_line += 1,
-
                    _ => {
-
                        old_line += 1;
-
                        new_line += 1;
-
                    }
-
                }
-
            } else {
-
                comment.push_str(line);
-
                comment.push('\n');
-
            }
-
        }
-
        if !comment.is_empty() {
-
            self.add_comment(
-
                &hunk,
-
                &comment,
-
                old_start..old_line - 1,
-
                new_start..new_line - 1,
-
            );
-
        }
-
        self
-
    }
-

-
    fn add_comment(
-
        &mut self,
-
        hunk: &HunkHeader,
-
        comment: &str,
-
        mut old_range: Range<usize>,
-
        mut new_range: Range<usize>,
-
    ) {
-
        // Empty lines between quoted text can generate empty comments
-
        // that should be filtered out.
-
        if comment.trim().is_empty() {
-
            return;
-
        }
-
        // Top-level comment, it should apply to the whole hunk.
-
        if old_range.is_empty() && new_range.is_empty() {
-
            old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
-
            new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
-
        }
-
        let old_range = old_range
-
            .is_empty()
-
            .not()
-
            .then_some(old_range)
-
            .map(|range| CodeRange::Lines { range });
-
        let new_range = (new_range)
-
            .is_empty()
-
            .not()
-
            .then_some(new_range)
-
            .map(|range| CodeRange::Lines { range });
-

-
        self.comments.push(ReviewComment {
-
            location: CodeLocation {
-
                commit: self.commit,
-
                path: self.path.clone(),
-
                old: old_range,
-
                new: new_range,
-
            },
-
            body: comment.trim().to_owned(),
-
        });
-
    }
-

-
    fn comments(self) -> Vec<ReviewComment> {
-
        self.comments
-
    }
-
}
-
#[cfg(test)]
-
mod tests {
-
    use super::*;
-

-
    #[test]
-
    fn test_review_comments_basic() {
-
        let input = r#"
-
> @@ -2559,18 +2560,18 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-

-
Comment #1.
-

-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-

-
Comment #2.
-

-
> +        if connect.len() < wanted {
-
> +            log::debug!(
-
>                  target: "service",
-
> -                "Not enough available peers to connect to (available={}, target={target})",
-
> -                available.len()
-

-
Comment #3.
-

-
> +                "Not enough available peers to connect to (available={}, wanted={wanted})",
-

-
Comment #4.
-

-
> +                connect.len()
-
>              );
-
>          }
-
> -        for (id, ka) in available {
-
> +        for (id, ka) in connect {
-
>              self.connect(id, ka.addr.clone());
-
>          }
-
>     }
-

-
Comment #5.
-

-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
-
                body: "Comment #1.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
-
                body: "Comment #2.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
-
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
-
                },
-
                body: "Comment #3.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: None,
-
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
-
                },
-
                body: "Comment #4.".to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
-
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
-
                },
-
                body: "Comment #5.".to_owned(),
-
            }),
-
        ];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 18,
-
                new_line_no: 2560,
-
                new_size: 18,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_multiline() {
-
        let input = r#"
-
> @@ -2559,9 +2560,7 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-

-
Blah blah blah blah blah blah blah.
-
Blah blah blah.
-

-
Blaah blaah blaah blaah blaah blaah blaah.
-
blaah blaah blaah.
-

-
Blaaah blaaah blaaah.
-

-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-

-
Woof woof.
-
Woof.
-
Woof.
-

-
Woof.
-

-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
-
                body: r#"
-
Blah blah blah blah blah blah blah.
-
Blah blah blah.
-

-
Blaah blaah blaah blaah blaah blaah blaah.
-
blaah blaah blaah.
-

-
Blaaah blaaah blaaah.
-
"#
-
                .trim()
-
                .to_owned(),
-
            }),
-
            (ReviewComment {
-
                location: CodeLocation {
-
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
-
                body: r#"
-
Woof woof.
-
Woof.
-
Woof.
-

-
Woof.
-
"#
-
                .trim()
-
                .to_owned(),
-
            }),
-
        ];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_before() {
-
        let input = r#"
-
This is a top-level comment.
-

-
> @@ -2559,9 +2560,7 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-
> -            .collect::<Vec<_>>(); // # -2564
-
> +            });
-
>
-
> -        if available.len() < target {
-
> -            log::warn!( # -2567
-
> +        // Peers we are going to attempt connections to.
-
> +        let connect = available.take(wanted).collect::<Vec<_>>();
-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
-
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2559..2569 }),
-
                new: Some(CodeRange::Lines { range: 2560..2568 }),
-
            },
-
            body: "This is a top-level comment.".to_owned(),
-
        })];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-

-
    #[test]
-
    fn test_review_comments_split_hunk() {
-
        let input = r#"
-
> @@ -2559,6 +2560,4 @@ where
-
>                  // Only consider onion addresses if configured.
-
>                  AddressType::Onion => self.config.onion.is_some(),
-
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
-
> -            })
-
> -            .take(wanted)
-

-
> -            .collect::<Vec<_>>();
-
> +            });
-

-
Comment on a split hunk.
-
"#;
-

-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
-
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2564..2565 }),
-
                new: Some(CodeRange::Lines { range: 2563..2564 }),
-
            },
-
            body: "Comment on a split hunk.".to_owned(),
-
        })];
-

-
        let mut builder = CommentBuilder::new(commit, path.clone());
-
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 6,
-
                new_line_no: 2560,
-
                new_size: 4,
-
                text: vec![],
-
            },
-
            input,
-
        );
-
        let actual = builder.comments();
-

-
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
-

-
        for (left, right) in actual.iter().zip(expected) {
-
            assert_eq!(left, right);
-
        }
-
    }
-
}
modified crates/radicle-cli/src/git.rs
@@ -1,9 +1,5 @@
//! Git-related functions and types.

-
pub mod ddiff;
-
pub mod pretty_diff;
-
pub mod unified_diff;
-

use std::collections::HashSet;
use std::fmt::Display;
use std::fs::{File, OpenOptions};
deleted crates/radicle-cli/src/git/ddiff.rs
@@ -1,420 +0,0 @@
-
//! `DDiff` is a diff between diffs.  The type aids in the review of a `Patch` to a project by
-
//! providing useful context between `Patch` updates a regular `Diff` will miss.
-
//!
-
//! For example, lets start with a file containing a list of words.
-
//!
-
//! ```text
-
//! componentwise
-
//! reusing
-
//! simplest
-
//! crag
-
//! offended
-
//! omitting
-
//! ```
-
//! Where a change is proposed to the file replacing a set of lines.  The example includes the
-
//! `HunkHeader` "@ .. @" for completeness, but it can be mostly ignored.
-
//!
-
//! ```text
-
//! @@ -0,6 +0,6 @@
-
//! componentwise
-
//! reusing
-
//! -simplest
-
//! -crag
-
//! -offended
-
//! +interpreters
-
//! +soiled
-
//! +snuffing
-
//! omitting
-
//! ```
-
//!
-
//! The author updates the `Patch` to keep 'offended' and remove 'interpreters'.
-
//!
-
//! ```text
-
//! @@ -0,6 +0,6 @@
-
//! componentwise
-
//! reusing
-
//! -simplest
-
//! -crag
-
//!  offended
-
//! -interpreters
-
//! +soiled
-
//! +snuffing
-
//! omitting
-
//! ```
-
//! The `DDiff` will show the what changes are being made, overlaid on to the original diff and
-
//! the diff's original file as context.
-
//!
-
//! ```text
-
//! @@ -0,9 +0,8 @@
-
//!   componentwise
-
//!   reusing
-
//!  -simplest
-
//!  -crag
-
//! --offended
-
//! + offended
-
//! -+interpreters
-
//!  +soiled
-
//!  +snuffing
-
//!   omitting
-
//! ```
-
//!
-
//! An alternative is to review a `Diff` between the resulting files after the first and second
-
//! Patch versions were applied.  The first `Patch` changes and original file contents are one
-
//! making it unclear what are changes to the `Patch` or changes to the original file.
-
//!
-
//! ```text
-
//! @@ -0,9 +0,8 @@
-
//!  componentwise
-
//!  reusing
-
//! +offended
-
//! -interpreters
-
//!  soiled
-
//!  snuffing
-
//!  omitting
-
//! ```
-
use radicle_surf::diff::*;
-

-
use std::io;
-

-
use crate::git::unified_diff;
-
use crate::git::unified_diff::{Encode, Writer};
-
use crate::terminal as term;
-

-
/// Either the modification of a single diff [`Line`], or just contextual
-
/// information.
-
#[derive(Clone, Debug, PartialEq, Eq)]
-
pub enum DiffModification {
-
    /// An addition line is to be added.
-
    AdditionAddition { line: Line, line_no: u32 },
-
    AdditionContext {
-
        line: Line,
-
        line_no_old: u32,
-
        line_no_new: u32,
-
    },
-
    /// An addition line is to be removed.
-
    AdditionDeletion { line: Line, line_no: u32 },
-
    /// A context line is to be added.
-
    ContextAddition { line: Line, line_no: u32 },
-
    /// A contextual line in a file, i.e. there were no changes to the line.
-
    ContextContext {
-
        line: Line,
-
        line_no_old: u32,
-
        line_no_new: u32,
-
    },
-
    /// A context line is to be removed.
-
    ContextDeletion { line: Line, line_no: u32 },
-
    /// A deletion line is to be added.
-
    DeletionAddition { line: Line, line_no: u32 },
-
    /// A deletion line in a diff, i.e. there were no changes to the line.
-
    DeletionContext {
-
        line: Line,
-
        line_no_old: u32,
-
        line_no_new: u32,
-
    },
-
    /// A deletion line is to be removed.
-
    DeletionDeletion { line: Line, line_no: u32 },
-
}
-

-
impl unified_diff::Decode for Hunk<DiffModification> {
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, unified_diff::Error> {
-
        let header = unified_diff::HunkHeader::decode(r)?;
-

-
        let mut lines = Vec::new();
-
        let mut new_line: u32 = 0;
-
        let mut old_line: u32 = 0;
-

-
        while old_line < header.old_size || new_line < header.new_size {
-
            if old_line > header.old_size {
-
                return Err(unified_diff::Error::syntax(format!(
-
                    "expected '{0}' old lines",
-
                    header.old_size,
-
                )));
-
            } else if new_line > header.new_size {
-
                return Err(unified_diff::Error::syntax(format!(
-
                    "expected '{0}' new lines",
-
                    header.new_size,
-
                )));
-
            }
-

-
            let mut line = DiffModification::decode(r).map_err(|e| {
-
                if e.is_eof() {
-
                    unified_diff::Error::syntax(format!(
-
                        "expected '{}' old lines and '{}' new lines, but found '{}' and '{}'",
-
                        header.old_size, header.new_size, old_line, new_line,
-
                    ))
-
                } else {
-
                    e
-
                }
-
            })?;
-

-
            match &mut line {
-
                DiffModification::AdditionAddition { line_no, .. } => {
-
                    *line_no = new_line;
-
                    new_line += 1;
-
                }
-
                DiffModification::AdditionContext {
-
                    line_no_old,
-
                    line_no_new,
-
                    ..
-
                } => {
-
                    *line_no_old = old_line;
-
                    *line_no_new = new_line;
-
                    old_line += 1;
-
                    new_line += 1;
-
                }
-
                DiffModification::AdditionDeletion { line_no, .. } => {
-
                    *line_no = old_line;
-
                    old_line += 1;
-
                }
-
                DiffModification::ContextAddition { line_no, .. } => {
-
                    *line_no = new_line;
-
                    new_line += 1;
-
                }
-
                DiffModification::ContextContext {
-
                    line_no_old,
-
                    line_no_new,
-
                    ..
-
                } => {
-
                    *line_no_old = old_line;
-
                    *line_no_new = new_line;
-
                    old_line += 1;
-
                    new_line += 1;
-
                }
-
                DiffModification::ContextDeletion { line_no, .. } => {
-
                    *line_no = old_line;
-
                    old_line += 1;
-
                }
-
                DiffModification::DeletionAddition { line_no, .. } => {
-
                    *line_no = new_line;
-
                    new_line += 1;
-
                }
-
                DiffModification::DeletionContext {
-
                    line_no_old,
-
                    line_no_new,
-
                    ..
-
                } => {
-
                    *line_no_old = old_line;
-
                    *line_no_new = new_line;
-
                    old_line += 1;
-
                    new_line += 1;
-
                }
-
                DiffModification::DeletionDeletion { line_no, .. } => {
-
                    *line_no = old_line;
-
                    old_line += 1;
-
                }
-
            };
-

-
            lines.push(line);
-
        }
-

-
        Ok(Hunk {
-
            header: Line::from(header.to_unified_string()?),
-
            lines,
-
            old: header.old_line_range(),
-
            new: header.new_line_range(),
-
        })
-
    }
-
}
-

-
impl unified_diff::Encode for Hunk<DiffModification> {
-
    fn encode(&self, w: &mut Writer) -> Result<(), unified_diff::Error> {
-
        // TODO: Remove trailing newlines accurately.
-
        // trim_end() will destroy diff information if the diff has a trailing whitespace on
-
        // purpose.
-
        w.magenta(self.header.from_utf8_lossy().trim_end())?;
-
        for l in &self.lines {
-
            l.encode(w)?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
/// The DDiff version of `FileDiff`.
-
#[derive(Clone, Debug, PartialEq)]
-
pub struct FileDDiff {
-
    pub path: std::path::PathBuf,
-
    pub old: DiffFile,
-
    pub new: DiffFile,
-
    pub hunks: Hunks<DiffModification>,
-
    pub eof: EofNewLine,
-
}
-

-
impl From<&FileDDiff> for unified_diff::FileHeader {
-
    fn from(value: &FileDDiff) -> Self {
-
        unified_diff::FileHeader::Modified {
-
            path: value.path.clone(),
-
            old: value.old.clone(),
-
            new: value.new.clone(),
-
            binary: false,
-
        }
-
    }
-
}
-

-
impl unified_diff::Decode for DiffModification {
-
    fn decode(r: &mut impl std::io::BufRead) -> Result<Self, unified_diff::Error> {
-
        let mut line = String::new();
-
        if r.read_line(&mut line)? == 0 {
-
            return Err(unified_diff::Error::UnexpectedEof);
-
        }
-

-
        let mut chars = line.chars();
-

-
        let first = chars.next().ok_or(unified_diff::Error::UnexpectedEof)?;
-
        let second = chars.next().ok_or(unified_diff::Error::UnexpectedEof)?;
-

-
        let line = match (first, second) {
-
            ('+', '+') => DiffModification::AdditionAddition {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            ('+', '-') => DiffModification::DeletionAddition {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            ('+', ' ') => DiffModification::ContextAddition {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            ('-', '+') => DiffModification::AdditionDeletion {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            ('-', '-') => DiffModification::DeletionDeletion {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            ('-', ' ') => DiffModification::ContextDeletion {
-
                line: chars.as_str().to_string().into(),
-
                line_no: 0,
-
            },
-
            (' ', '+') => DiffModification::AdditionContext {
-
                line: chars.as_str().to_string().into(),
-
                line_no_old: 0,
-
                line_no_new: 0,
-
            },
-
            (' ', '-') => DiffModification::DeletionContext {
-
                line: chars.as_str().to_string().into(),
-
                line_no_old: 0,
-
                line_no_new: 0,
-
            },
-
            (' ', ' ') => DiffModification::ContextContext {
-
                line: chars.as_str().to_string().into(),
-
                line_no_old: 0,
-
                line_no_new: 0,
-
            },
-
            (v1, v2) => {
-
                return Err(unified_diff::Error::syntax(format!(
-
                    "indicator character expected, but got '{v1}{v2}'"
-
                )))
-
            }
-
        };
-

-
        Ok(line)
-
    }
-
}
-

-
impl unified_diff::Encode for DiffModification {
-
    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
-
        match self {
-
            DiffModification::AdditionAddition { line, .. } => {
-
                let s = format!("++{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Green))?;
-
            }
-
            DiffModification::AdditionDeletion { line, .. } => {
-
                let s = format!("-+{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Red))?;
-
            }
-
            DiffModification::ContextAddition { line, .. } => {
-
                let s = format!("+ {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Green))?;
-
            }
-
            DiffModification::DeletionAddition { line, .. } => {
-
                let s = format!("+-{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Green))?;
-
            }
-
            DiffModification::DeletionDeletion { line, .. } => {
-
                let s = format!("--{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Red))?;
-
            }
-
            DiffModification::ContextDeletion { line, .. } => {
-
                let s = format!("- {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Red))?;
-
            }
-
            DiffModification::AdditionContext { line, .. } => {
-
                let s = format!(" +{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Green).dim())?
-
            }
-
            DiffModification::DeletionContext { line, .. } => {
-
                let s = format!(" -{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Red).dim())?;
-
            }
-
            DiffModification::ContextContext { line, .. } => {
-
                let s = format!("  {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::default().dim())?;
-
            }
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
impl unified_diff::Encode for FileDDiff {
-
    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
-
        w.encode(&unified_diff::FileHeader::from(self))?;
-
        for h in self.hunks.iter() {
-
            h.encode(w)?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
/// A diff of a diff.
-
#[derive(Clone, Debug, PartialEq, Default)]
-
pub struct DDiff {
-
    files: Vec<FileDDiff>,
-
}
-

-
impl DDiff {
-
    /// Returns an iterator of the file in the diff.
-
    pub fn files(&self) -> impl Iterator<Item = &FileDDiff> {
-
        self.files.iter()
-
    }
-

-
    /// Returns owned files in the diff.
-
    pub fn into_files(self) -> Vec<FileDDiff> {
-
        self.files
-
    }
-
}
-

-
impl unified_diff::Encode for DDiff {
-
    fn encode(&self, w: &mut unified_diff::Writer) -> Result<(), unified_diff::Error> {
-
        for v in self.files() {
-
            v.encode(w)?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
#[cfg(test)]
-
mod tests {
-
    use super::*;
-

-
    use crate::git::unified_diff::{Decode, Encode};
-

-
    #[test]
-
    fn diff_encode_decode_ddiff_hunk() {
-
        let ddiff = Hunk::<DiffModification>::parse(include_str!(concat!(
-
            env!("CARGO_MANIFEST_DIR"),
-
            "/tests/data/ddiff_hunk.diff"
-
        )))
-
        .unwrap();
-
        assert_eq!(
-
            include_str!(concat!(
-
                env!("CARGO_MANIFEST_DIR"),
-
                "/tests/data/ddiff_hunk.diff"
-
            )),
-
            ddiff.to_unified_string().unwrap()
-
        );
-
    }
-
}
deleted crates/radicle-cli/src/git/pretty_diff.rs
@@ -1,628 +0,0 @@
-
use std::fs;
-
use std::path::{Path, PathBuf};
-

-
use radicle::git;
-
use radicle::git::Oid;
-
use radicle_surf::diff;
-
use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved};
-
use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
-
use radicle_term as term;
-
use term::cell::Cell;
-
use term::VStack;
-

-
use crate::git::unified_diff::FileHeader;
-
use crate::terminal::highlight::{Highlighter, Theme};
-

-
use super::unified_diff::{Decode, HunkHeader};
-

-
/// Blob returned by the [`Repo`] trait.
-
#[derive(PartialEq, Eq, Debug)]
-
pub enum Blob {
-
    Binary,
-
    Empty,
-
    Plain(Vec<u8>),
-
}
-

-
/// A repository of Git blobs.
-
pub trait Repo {
-
    /// Lookup a blob from the repo.
-
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
-
    /// Lookup a file in the workdir.
-
    fn file(&self, path: &Path) -> Option<Blob>;
-
}
-

-
impl Repo for git::raw::Repository {
-
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
-
        let blob = self.find_blob(oid.into())?;
-

-
        if blob.is_binary() {
-
            Ok(Blob::Binary)
-
        } else {
-
            let content = blob.content();
-

-
            if content.is_empty() {
-
                Ok(Blob::Empty)
-
            } else {
-
                Ok(Blob::Plain(blob.content().to_vec()))
-
            }
-
        }
-
    }
-

-
    fn file(&self, path: &Path) -> Option<Blob> {
-
        self.workdir()
-
            .and_then(|dir| fs::read(dir.join(path)).ok())
-
            .map(|content| {
-
                // A file is considered binary if there is a zero byte in the first 8 kilobytes
-
                // of the file. This is the same heuristic Git uses.
-
                let binary = content.iter().take(8192).any(|b| *b == 0);
-
                if binary {
-
                    Blob::Binary
-
                } else {
-
                    Blob::Plain(content)
-
                }
-
            })
-
    }
-
}
-

-
/// Blobs passed down to the hunk renderer.
-
#[derive(Debug)]
-
pub struct Blobs<T> {
-
    pub old: Option<T>,
-
    pub new: Option<T>,
-
}
-

-
impl<T> Blobs<T> {
-
    pub fn new(old: Option<T>, new: Option<T>) -> Self {
-
        Self { old, new }
-
    }
-
}
-

-
impl Blobs<(PathBuf, Blob)> {
-
    pub fn highlight(&self, hi: &mut Highlighter) -> Blobs<Vec<term::Line>> {
-
        let mut blobs = Blobs::default();
-
        if let Some((path, Blob::Plain(content))) = &self.old {
-
            blobs.old = hi.highlight(path, content).ok();
-
        }
-
        if let Some((path, Blob::Plain(content))) = &self.new {
-
            blobs.new = hi.highlight(path, content).ok();
-
        }
-
        blobs
-
    }
-

-
    pub fn from_paths<R: Repo>(
-
        old: Option<(&Path, Oid)>,
-
        new: Option<(&Path, Oid)>,
-
        repo: &R,
-
    ) -> Blobs<(PathBuf, Blob)> {
-
        Blobs::new(
-
            old.and_then(|(path, oid)| {
-
                repo.blob(oid)
-
                    .ok()
-
                    .or_else(|| repo.file(path))
-
                    .map(|blob| (path.to_path_buf(), blob))
-
            }),
-
            new.and_then(|(path, oid)| {
-
                repo.blob(oid)
-
                    .ok()
-
                    .or_else(|| repo.file(path))
-
                    .map(|blob| (path.to_path_buf(), blob))
-
            }),
-
        )
-
    }
-
}
-

-
impl<T> Default for Blobs<T> {
-
    fn default() -> Self {
-
        Self {
-
            old: None,
-
            new: None,
-
        }
-
    }
-
}
-

-
/// Types that can be rendered as pretty diffs.
-
pub trait ToPretty {
-
    /// The output of the render process.
-
    type Output: term::Element;
-
    /// Context that can be passed down from parent objects during rendering.
-
    type Context;
-

-
    /// Render to pretty diff output.
-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output;
-
}
-

-
impl ToPretty for Diff {
-
    type Output = term::VStack<'static>;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        term::VStack::default()
-
            .padding(0)
-
            .children(self.files().flat_map(|f| {
-
                [
-
                    f.pretty(hi, context, repo).boxed(),
-
                    term::Line::blank().boxed(), // Blank line between files.
-
                ]
-
            }))
-
    }
-
}
-

-
impl ToPretty for FileHeader {
-
    type Output = term::Line;
-
    type Context = Option<FileStats>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        _hi: &mut Highlighter,
-
        stats: &Self::Context,
-
        _repo: &R,
-
    ) -> Self::Output {
-
        let theme = Theme::default();
-
        let (mut header, badge, binary) = match self {
-
            FileHeader::Added { path, binary, .. } => (
-
                term::Line::new(path.display().to_string()),
-
                Some(term::format::badge_positive("created")),
-
                *binary,
-
            ),
-
            FileHeader::Moved {
-
                old_path, new_path, ..
-
            } => (
-
                term::Line::spaced([
-
                    term::label(old_path.display().to_string()),
-
                    term::label("->".to_string()),
-
                    term::label(new_path.display().to_string()),
-
                ]),
-
                Some(term::format::badge_secondary("moved")),
-
                false,
-
            ),
-
            FileHeader::Deleted { path, binary, .. } => (
-
                term::Line::new(path.display().to_string()),
-
                Some(term::format::badge_negative("deleted")),
-
                *binary,
-
            ),
-
            FileHeader::Modified {
-
                path,
-
                old,
-
                new,
-
                binary,
-
                ..
-
            } => {
-
                if old.mode != new.mode {
-
                    (
-
                        term::Line::spaced([
-
                            term::label(path.display().to_string()),
-
                            term::label(format!("{:o}", u32::from(old.mode.clone())))
-
                                .fg(term::Color::Blue),
-
                            term::label("->".to_string()),
-
                            term::label(format!("{:o}", u32::from(new.mode.clone())))
-
                                .fg(term::Color::Blue),
-
                        ]),
-
                        Some(term::format::badge_secondary("mode changed")),
-
                        *binary,
-
                    )
-
                } else {
-
                    (term::Line::new(path.display().to_string()), None, *binary)
-
                }
-
            }
-
            FileHeader::Copied {
-
                old_path, new_path, ..
-
            } => (
-
                term::Line::spaced([
-
                    term::label(old_path.display().to_string()),
-
                    term::label("->".to_string()),
-
                    term::label(new_path.display().to_string()),
-
                ]),
-
                Some(term::format::badge_secondary("copied")),
-
                false,
-
            ),
-
        };
-

-
        if binary {
-
            header.push(term::Label::space());
-
            header.push(term::label(term::format::badge_yellow("binary")));
-
        }
-

-
        let (additions, deletions) = if let Some(stats) = stats {
-
            (stats.additions, stats.deletions)
-
        } else {
-
            (0, 0)
-
        };
-
        if deletions > 0 {
-
            header.push(term::Label::space());
-
            header.push(term::label(format!("-{deletions}")).fg(theme.color("negative.light")));
-
        }
-
        if additions > 0 {
-
            header.push(term::Label::space());
-
            header.push(term::label(format!("+{additions}")).fg(theme.color("positive.light")));
-
        }
-
        if let Some(badge) = badge {
-
            header.push(term::Label::space());
-
            header.push(badge);
-
        }
-
        header
-
    }
-
}
-

-
impl ToPretty for FileDiff {
-
    type Output = term::VStack<'static>;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        _context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let header = FileHeader::from(self);
-

-
        match self {
-
            FileDiff::Added(f) => f.pretty(hi, &header, repo),
-
            FileDiff::Deleted(f) => f.pretty(hi, &header, repo),
-
            FileDiff::Modified(f) => f.pretty(hi, &header, repo),
-
            FileDiff::Moved(f) => f.pretty(hi, &header, repo),
-
            FileDiff::Copied(f) => f.pretty(hi, &header, repo),
-
        }
-
    }
-
}
-

-
impl ToPretty for DiffContent {
-
    type Output = term::VStack<'static>;
-
    type Context = Blobs<(PathBuf, Blob)>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        blobs: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let mut vstack = term::VStack::default().padding(0);
-

-
        match self {
-
            DiffContent::Plain {
-
                hunks: Hunks(hunks),
-
                ..
-
            } => {
-
                let blobs = blobs.highlight(hi);
-

-
                for (i, h) in hunks.iter().enumerate() {
-
                    vstack.push(h.pretty(hi, &blobs, repo));
-
                    if i != hunks.len() - 1 {
-
                        vstack = vstack.divider();
-
                    }
-
                }
-
            }
-
            DiffContent::Empty => {}
-
            DiffContent::Binary => {}
-
        }
-
        vstack
-
    }
-
}
-

-
impl ToPretty for Moved {
-
    type Output = term::VStack<'static>;
-
    type Context = FileHeader;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        header: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let header = header.pretty(hi, &self.diff.stats().copied(), repo);
-

-
        term::VStack::default()
-
            .border(Some(term::colors::FAINT))
-
            .padding(1)
-
            .child(term::Line::default().extend(header))
-
    }
-
}
-

-
impl ToPretty for Added {
-
    type Output = term::VStack<'static>;
-
    type Context = FileHeader;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        header: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let old = None;
-
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
-

-
        pretty_modification(header, &self.diff, old, new, repo, hi)
-
    }
-
}
-

-
impl ToPretty for Deleted {
-
    type Output = term::VStack<'static>;
-
    type Context = FileHeader;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        header: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
-
        let new = None;
-

-
        pretty_modification(header, &self.diff, old, new, repo, hi)
-
    }
-
}
-

-
impl ToPretty for Modified {
-
    type Output = term::VStack<'static>;
-
    type Context = FileHeader;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        header: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
-
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
-

-
        pretty_modification(header, &self.diff, old, new, repo, hi)
-
    }
-
}
-

-
impl ToPretty for Copied {
-
    type Output = term::VStack<'static>;
-
    type Context = FileHeader;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        _context: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let header = FileHeader::Copied {
-
            old_path: self.old_path.clone(),
-
            new_path: self.old_path.clone(),
-
        }
-
        .pretty(hi, &self.diff.stats().copied(), repo);
-

-
        term::VStack::default()
-
            .border(Some(term::colors::FAINT))
-
            .padding(1)
-
            .child(header)
-
    }
-
}
-

-
impl ToPretty for HunkHeader {
-
    type Output = term::Line;
-
    type Context = ();
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        _hi: &mut Highlighter,
-
        _context: &Self::Context,
-
        _repo: &R,
-
    ) -> Self::Output {
-
        term::Line::spaced([
-
            term::label(format!(
-
                "@@ -{},{} +{},{} @@",
-
                self.old_line_no, self.old_size, self.new_line_no, self.new_size,
-
            ))
-
            .fg(term::colors::fixed::FAINT),
-
            term::label(String::from_utf8_lossy(&self.text).to_string())
-
                .fg(term::colors::fixed::DIM),
-
        ])
-
    }
-
}
-

-
impl ToPretty for Hunk<Modification> {
-
    type Output = term::VStack<'static>;
-
    type Context = Blobs<Vec<term::Line>>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        hi: &mut Highlighter,
-
        blobs: &Self::Context,
-
        repo: &R,
-
    ) -> Self::Output {
-
        let mut vstack = term::VStack::default().padding(0);
-
        let mut table = term::Table::<5, term::Filled<term::Line>>::new(term::TableOptions {
-
            overflow: false,
-
            spacing: 0,
-
            border: None,
-
        });
-
        let theme = Theme::default();
-

-
        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
-
            vstack.push(header.pretty(hi, &(), repo));
-
        }
-

-
        table.extend(
-
            self.lines
-
                .iter()
-
                .map(|line| line_to_table_row(hi, blobs, repo, &theme, line)),
-
        );
-

-
        vstack.push(table);
-
        vstack
-
    }
-
}
-

-
fn line_to_table_row<R: Repo>(
-
    hi: &mut Highlighter,
-
    blobs: &Blobs<Vec<radicle_term::Line>>,
-
    repo: &R,
-
    theme: &Theme,
-
    line: &Modification,
-
) -> [radicle_term::Filled<radicle_term::Line>; 5] {
-
    match line {
-
        Modification::Addition(a) => [
-
            term::Label::space()
-
                .pad(5)
-
                .bg(theme.color("positive"))
-
                .to_line()
-
                .filled(theme.color("positive")),
-
            term::label(a.line_no.to_string())
-
                .pad(5)
-
                .fg(theme.color("positive.light"))
-
                .to_line()
-
                .filled(theme.color("positive")),
-
            term::label(" + ")
-
                .fg(theme.color("positive.light"))
-
                .to_line()
-
                .filled(theme.color("positive.dark")),
-
            line.pretty(hi, blobs, repo)
-
                .filled(theme.color("positive.dark")),
-
            term::Line::blank().filled(term::Color::default()),
-
        ],
-
        Modification::Deletion(a) => [
-
            term::label(a.line_no.to_string())
-
                .pad(5)
-
                .fg(theme.color("negative.light"))
-
                .to_line()
-
                .filled(theme.color("negative")),
-
            term::Label::space()
-
                .pad(5)
-
                .fg(theme.color("dim"))
-
                .to_line()
-
                .filled(theme.color("negative")),
-
            term::label(" - ")
-
                .fg(theme.color("negative.light"))
-
                .to_line()
-
                .filled(theme.color("negative.dark")),
-
            line.pretty(hi, blobs, repo)
-
                .filled(theme.color("negative.dark")),
-
            term::Line::blank().filled(term::Color::default()),
-
        ],
-
        Modification::Context {
-
            line_no_old,
-
            line_no_new,
-
            ..
-
        } => [
-
            term::label(line_no_old.to_string())
-
                .pad(5)
-
                .fg(theme.color("dim"))
-
                .to_line()
-
                .filled(theme.color("faint")),
-
            term::label(line_no_new.to_string())
-
                .pad(5)
-
                .fg(theme.color("dim"))
-
                .to_line()
-
                .filled(theme.color("faint")),
-
            term::label("   ").to_line().filled(term::Color::default()),
-
            line.pretty(hi, blobs, repo).filled(term::Color::default()),
-
            term::Line::blank().filled(term::Color::default()),
-
        ],
-
    }
-
}
-

-
impl ToPretty for Modification {
-
    type Output = term::Line;
-
    type Context = Blobs<Vec<term::Line>>;
-

-
    fn pretty<R: Repo>(
-
        &self,
-
        _hi: &mut Highlighter,
-
        blobs: &Blobs<Vec<term::Line>>,
-
        _repo: &R,
-
    ) -> Self::Output {
-
        match self {
-
            Modification::Deletion(diff::Deletion { line, line_no }) => {
-
                if let Some(lines) = &blobs.old.as_ref() {
-
                    lines[*line_no as usize - 1].clone()
-
                } else {
-
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
-
                }
-
            }
-
            Modification::Addition(diff::Addition { line, line_no }) => {
-
                if let Some(lines) = &blobs.new.as_ref() {
-
                    lines[*line_no as usize - 1].clone()
-
                } else {
-
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
-
                }
-
            }
-
            Modification::Context {
-
                line, line_no_new, ..
-
            } => {
-
                // Nb. we can check in the old or the new blob, we choose the new.
-
                if let Some(lines) = &blobs.new.as_ref() {
-
                    lines[*line_no_new as usize - 1].clone()
-
                } else {
-
                    term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
-
                }
-
            }
-
        }
-
    }
-
}
-

-
/// Render a file added, deleted or modified.
-
fn pretty_modification<R: Repo>(
-
    header: &FileHeader,
-
    diff: &DiffContent,
-
    old: Option<(&Path, Oid)>,
-
    new: Option<(&Path, Oid)>,
-
    repo: &R,
-
    hi: &mut Highlighter,
-
) -> VStack<'static> {
-
    let blobs = Blobs::from_paths(old, new, repo);
-
    let header = header.pretty(hi, &diff.stats().copied(), repo);
-
    let vstack = term::VStack::default()
-
        .border(Some(term::colors::FAINT))
-
        .padding(1)
-
        .child(header);
-

-
    let body = diff.pretty(hi, &blobs, repo);
-
    if body.is_empty() {
-
        vstack
-
    } else {
-
        vstack.divider().merge(body)
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use std::ffi::OsStr;
-

-
    use term::Constraint;
-
    use term::Element;
-

-
    use super::*;
-
    use git::raw::RepositoryOpenFlags;
-
    use git::raw::{Oid, Repository};
-

-
    #[test]
-
    #[ignore]
-
    fn test_pretty() {
-
        let repo = Repository::open_ext::<_, _, &[&OsStr]>(
-
            env!("CARGO_MANIFEST_DIR"),
-
            RepositoryOpenFlags::all(),
-
            &[],
-
        )
-
        .unwrap();
-
        let commit = repo
-
            .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
-
            .unwrap();
-
        let parent = commit.parents().next().unwrap();
-
        let old_tree = parent.tree().unwrap();
-
        let new_tree = commit.tree().unwrap();
-
        let diff = repo
-
            .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
-
            .unwrap();
-
        let diff = Diff::try_from(diff).unwrap();
-

-
        let mut hi = Highlighter::default();
-
        let pretty = diff.pretty(&mut hi, &(), &repo);
-

-
        pretty
-
            .write(Constraint::from_env().unwrap_or_default())
-
            .unwrap();
-
    }
-
}
deleted crates/radicle-cli/src/git/unified_diff.rs
@@ -1,648 +0,0 @@
-
//! Formatting support for Git's [diff format](https://git-scm.com/docs/diff-format).
-
use std::fmt;
-
use std::io;
-
use std::path::PathBuf;
-

-
use radicle_surf::diff::FileStats;
-
use thiserror::Error;
-

-
use radicle::git;
-
use radicle_surf::diff;
-
use radicle_surf::diff::{Diff, DiffContent, DiffFile, FileDiff, Hunk, Hunks, Line, Modification};
-

-
use crate::terminal as term;
-

-
#[derive(Debug, Error)]
-
pub enum Error {
-
    /// Attempt to decode from a source with no data left.
-
    #[error("unexpected end of file")]
-
    UnexpectedEof,
-
    #[error(transparent)]
-
    Io(#[from] io::Error),
-
    /// Catchall for syntax error messages.
-
    #[error("{0}")]
-
    Syntax(String),
-
    #[error(transparent)]
-
    ParseInt(#[from] std::num::ParseIntError),
-
    #[error(transparent)]
-
    Utf8(#[from] std::string::FromUtf8Error),
-
}
-

-
impl Error {
-
    pub fn syntax(msg: impl ToString) -> Self {
-
        Self::Syntax(msg.to_string())
-
    }
-

-
    pub fn is_eof(&self) -> bool {
-
        match self {
-
            Self::UnexpectedEof => true,
-
            Self::Io(e) => e.kind() == io::ErrorKind::UnexpectedEof,
-
            _ => false,
-
        }
-
    }
-
}
-

-
/// The kind of FileDiff Header which can be used to print the FileDiff information which precedes
-
/// `Hunks`.
-
#[derive(Debug, Clone, PartialEq)]
-
pub enum FileHeader {
-
    Added {
-
        path: PathBuf,
-
        new: DiffFile,
-
        binary: bool,
-
    },
-
    Copied {
-
        old_path: PathBuf,
-
        new_path: PathBuf,
-
    },
-
    Deleted {
-
        path: PathBuf,
-
        old: DiffFile,
-
        binary: bool,
-
    },
-
    Modified {
-
        path: PathBuf,
-
        old: DiffFile,
-
        new: DiffFile,
-
        binary: bool,
-
    },
-
    Moved {
-
        old_path: PathBuf,
-
        new_path: PathBuf,
-
    },
-
}
-

-
impl std::convert::From<&FileDiff> for FileHeader {
-
    // TODO: Pathnames with 'unusual names' need to be quoted.
-
    fn from(value: &FileDiff) -> Self {
-
        match value {
-
            FileDiff::Modified(v) => FileHeader::Modified {
-
                path: v.path.clone(),
-
                old: v.old.clone(),
-
                new: v.new.clone(),
-
                binary: matches!(v.diff, DiffContent::Binary),
-
            },
-
            FileDiff::Added(v) => FileHeader::Added {
-
                path: v.path.clone(),
-
                new: v.new.clone(),
-
                binary: matches!(v.diff, DiffContent::Binary),
-
            },
-
            FileDiff::Copied(c) => FileHeader::Copied {
-
                old_path: c.old_path.clone(),
-
                new_path: c.new_path.clone(),
-
            },
-
            FileDiff::Deleted(v) => FileHeader::Deleted {
-
                path: v.path.clone(),
-
                old: v.old.clone(),
-
                binary: matches!(v.diff, DiffContent::Binary),
-
            },
-
            FileDiff::Moved(v) => FileHeader::Moved {
-
                old_path: v.old_path.clone(),
-
                new_path: v.new_path.clone(),
-
            },
-
        }
-
    }
-
}
-

-
/// Meta data which precedes a `Hunk`s content.
-
///
-
/// For example:
-
/// @@ -24,8 +24,6 @@ use radicle_surf::diff::*;
-
#[derive(Clone, Debug, Default, PartialEq)]
-
pub struct HunkHeader {
-
    /// Line the hunk started in the old file.
-
    pub old_line_no: u32,
-
    /// Number of removed and context lines.
-
    pub old_size: u32,
-
    /// Line the hunk started in the new file.
-
    pub new_line_no: u32,
-
    /// Number of added and context lines.
-
    pub new_size: u32,
-
    /// Trailing text for the Hunk Header.
-
    ///
-
    /// From Git's documentation "Hunk headers mention the name of the function to which the hunk
-
    /// applies. See "Defining a custom hunk-header" in gitattributes for details of how to tailor
-
    /// to this to specific languages.".  It is likely best to leave this empty when generating
-
    /// diffs.
-
    pub text: Vec<u8>,
-
}
-

-
impl TryFrom<&Hunk<Modification>> for HunkHeader {
-
    type Error = Error;
-

-
    fn try_from(hunk: &Hunk<Modification>) -> Result<Self, Self::Error> {
-
        let mut r = io::BufReader::new(hunk.header.as_bytes());
-
        Self::decode(&mut r)
-
    }
-
}
-

-
impl HunkHeader {
-
    pub fn old_line_range(&self) -> std::ops::Range<u32> {
-
        let start: u32 = self.old_line_no;
-
        let end: u32 = self.old_line_no + self.old_size;
-
        start..end + 1
-
    }
-

-
    pub fn new_line_range(&self) -> std::ops::Range<u32> {
-
        let start: u32 = self.new_line_no;
-
        let end: u32 = self.new_line_no + self.new_size;
-
        start..end + 1
-
    }
-
}
-

-
/// Diff-related types that can be decoded from the unified diff format.
-
pub trait Decode: Sized {
-
    /// Decode, and fail if we reach the end of the stream.
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error>;
-

-
    /// Decode, and return a `None` if we reached the end of the stream.
-
    fn try_decode(r: &mut impl io::BufRead) -> Result<Option<Self>, Error> {
-
        match Self::decode(r) {
-
            Ok(v) => Ok(Some(v)),
-
            Err(Error::UnexpectedEof) => Ok(None),
-
            Err(e) => Err(e),
-
        }
-
    }
-

-
    /// Decode from a string input.
-
    fn parse(s: &str) -> Result<Self, Error> {
-
        Self::from_bytes(s.as_bytes())
-
    }
-

-
    /// Decode from a string input.
-
    fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
-
        let mut r = io::BufReader::new(bytes);
-
        Self::decode(&mut r)
-
    }
-
}
-

-
/// Diff-related types that can be encoded intro the unified diff format.
-
pub trait Encode: Sized {
-
    /// Encode type into diff writer.
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error>;
-

-
    /// Encode into unified diff string.
-
    fn to_unified_string(&self) -> Result<String, Error> {
-
        let mut buf = Vec::new();
-
        let mut w = Writer::new(&mut buf);
-

-
        w.encode(self)?;
-
        drop(w);
-

-
        String::from_utf8(buf).map_err(Error::from)
-
    }
-
}
-

-
impl Decode for Diff {
-
    /// Decode from git's unified diff format, consuming the entire input.
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
-
        let mut s = String::new();
-

-
        r.read_to_string(&mut s)?;
-

-
        let d = git::raw::Diff::from_buffer(s.as_ref())
-
            .map_err(|e| Error::syntax(format!("decoding unified diff: {e}")))?;
-
        let d =
-
            Diff::try_from(d).map_err(|e| Error::syntax(format!("decoding unified diff: {e}")))?;
-

-
        Ok(d)
-
    }
-
}
-

-
impl Encode for Diff {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        for fdiff in self.files() {
-
            fdiff.encode(w)?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl Decode for DiffContent {
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
-
        let mut hunks = Vec::default();
-
        let mut additions = 0;
-
        let mut deletions = 0;
-

-
        while let Some(h) = Hunk::try_decode(r)? {
-
            for l in &h.lines {
-
                match l {
-
                    Modification::Addition(_) => additions += 1,
-
                    Modification::Deletion(_) => deletions += 1,
-
                    _ => {}
-
                }
-
            }
-
            hunks.push(h);
-
        }
-

-
        if hunks.is_empty() {
-
            Ok(DiffContent::Empty)
-
        } else {
-
            // TODO: Handle case for binary.
-
            Ok(DiffContent::Plain {
-
                hunks: Hunks::from(hunks),
-
                stats: FileStats {
-
                    additions,
-
                    deletions,
-
                },
-
                // TODO: Properly handle EndOfLine field
-
                eof: diff::EofNewLine::NoneMissing,
-
            })
-
        }
-
    }
-
}
-

-
impl Encode for DiffContent {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        match self {
-
            DiffContent::Plain { hunks, .. } => {
-
                for h in hunks.iter() {
-
                    h.encode(w)?;
-
                }
-
            }
-
            DiffContent::Empty => {}
-
            DiffContent::Binary => todo!("DiffContent::Binary encoding not implemented"),
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl Encode for FileDiff {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        w.encode(&FileHeader::from(self))?;
-
        match self {
-
            FileDiff::Modified(f) => {
-
                w.encode(&f.diff)?;
-
            }
-
            FileDiff::Added(f) => {
-
                w.encode(&f.diff)?;
-
            }
-
            FileDiff::Copied(f) => {
-
                w.encode(&f.diff)?;
-
            }
-
            FileDiff::Deleted(f) => {
-
                w.encode(&f.diff)?;
-
            }
-
            FileDiff::Moved(f) => {
-
                // Nb. We only display diffs as moves when the file was not changed.
-
                w.encode(&f.diff)?;
-
            }
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
impl Encode for FileHeader {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        match self {
-
            FileHeader::Modified { path, old, new, .. } => {
-
                w.meta(format!(
-
                    "diff --git a/{} b/{}",
-
                    path.display(),
-
                    path.display()
-
                ))?;
-

-
                if old.mode == new.mode {
-
                    w.meta(format!(
-
                        "index {}..{} {:o}",
-
                        term::format::oid(*old.oid),
-
                        term::format::oid(*new.oid),
-
                        u32::from(old.mode.clone()),
-
                    ))?;
-
                } else {
-
                    w.meta(format!("old mode {:o}", u32::from(old.mode.clone())))?;
-
                    w.meta(format!("new mode {:o}", u32::from(new.mode.clone())))?;
-
                    w.meta(format!(
-
                        "index {}..{}",
-
                        term::format::oid(*old.oid),
-
                        term::format::oid(*new.oid)
-
                    ))?;
-
                }
-

-
                w.meta(format!("--- a/{}", path.display()))?;
-
                w.meta(format!("+++ b/{}", path.display()))?;
-
            }
-
            FileHeader::Added { path, new, .. } => {
-
                w.meta(format!(
-
                    "diff --git a/{} b/{}",
-
                    path.display(),
-
                    path.display()
-
                ))?;
-

-
                w.meta(format!("new file mode {:o}", u32::from(new.mode.clone())))?;
-
                w.meta(format!(
-
                    "index {}..{}",
-
                    term::format::oid(git::Oid::sha1_zero()),
-
                    term::format::oid(*new.oid),
-
                ))?;
-

-
                w.meta("--- /dev/null")?;
-
                w.meta(format!("+++ b/{}", path.display()))?;
-
            }
-
            FileHeader::Copied { .. } => todo!(),
-
            FileHeader::Deleted { path, old, .. } => {
-
                w.meta(format!(
-
                    "diff --git a/{} b/{}",
-
                    path.display(),
-
                    path.display()
-
                ))?;
-

-
                w.meta(format!(
-
                    "deleted file mode {:o}",
-
                    u32::from(old.mode.clone())
-
                ))?;
-
                w.meta(format!(
-
                    "index {}..{}",
-
                    term::format::oid(*old.oid),
-
                    term::format::oid(git::Oid::sha1_zero())
-
                ))?;
-

-
                w.meta(format!("--- a/{}", path.display()))?;
-
                w.meta("+++ /dev/null".to_string())?;
-
            }
-
            FileHeader::Moved { old_path, new_path } => {
-
                w.meta(format!(
-
                    "diff --git a/{} b/{}",
-
                    old_path.display(),
-
                    new_path.display()
-
                ))?;
-
                w.meta("similarity index 100%")?;
-
                w.meta(format!("rename from {}", old_path.display()))?;
-
                w.meta(format!("rename to {}", new_path.display()))?;
-
            }
-
        };
-
        Ok(())
-
    }
-
}
-

-
impl Decode for HunkHeader {
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
-
        let mut line = String::default();
-
        if r.read_line(&mut line)? == 0 {
-
            return Err(Error::UnexpectedEof);
-
        };
-

-
        let mut header = HunkHeader::default();
-
        let s = line
-
            .strip_prefix("@@ -")
-
            .ok_or(Error::syntax("missing '@@ -'"))?;
-

-
        let (old, s) = s
-
            .split_once(" +")
-
            .ok_or(Error::syntax("missing new line information"))?;
-
        let (line_no, size) = old.split_once(',').unwrap_or((old, "1"));
-

-
        header.old_line_no = line_no.parse()?;
-
        header.old_size = size.parse()?;
-

-
        let (new, s) = s
-
            .split_once(" @@")
-
            .ok_or(Error::syntax("closing '@@' is missing"))?;
-
        let (line_no, size) = new.split_once(',').unwrap_or((new, "1"));
-

-
        header.new_line_no = line_no.parse()?;
-
        header.new_size = size.parse()?;
-

-
        let s = s.strip_prefix(' ').unwrap_or(s);
-
        header.text = s.as_bytes().to_vec();
-

-
        Ok(header)
-
    }
-
}
-

-
impl Encode for HunkHeader {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        let old = if self.old_size == 1 {
-
            format!("{}", self.old_line_no)
-
        } else {
-
            format!("{},{}", self.old_line_no, self.old_size)
-
        };
-
        let new = if self.new_size == 1 {
-
            format!("{}", self.new_line_no)
-
        } else {
-
            format!("{},{}", self.new_line_no, self.new_size)
-
        };
-
        let text = if self.text.is_empty() {
-
            "".to_string()
-
        } else {
-
            format!(" {}", String::from_utf8_lossy(&self.text))
-
        };
-
        w.meta(format!("@@ -{old} +{new} @@{text}"))?;
-

-
        Ok(())
-
    }
-
}
-

-
impl Decode for Hunk<Modification> {
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
-
        let header = HunkHeader::decode(r)?;
-

-
        let mut lines = Vec::new();
-
        let mut new_line: u32 = 0;
-
        let mut old_line: u32 = 0;
-

-
        while old_line < header.old_size || new_line < header.new_size {
-
            if old_line > header.old_size {
-
                return Err(Error::syntax(format!(
-
                    "expected '{}' old lines",
-
                    header.old_size
-
                )));
-
            } else if new_line > header.new_size {
-
                return Err(Error::syntax(format!(
-
                    "expected '{0}' new lines",
-
                    header.new_size
-
                )));
-
            }
-

-
            let Some(line) = Modification::try_decode(r)? else {
-
                return Err(Error::syntax(format!(
-
                    "expected '{}' old lines and '{}' new lines, but found '{}' and '{}'",
-
                    header.old_size, header.new_size, old_line, new_line,
-
                )));
-
            };
-

-
            let line = match line {
-
                Modification::Addition(v) => {
-
                    let l = Modification::addition(v.line, header.new_line_no + new_line);
-
                    new_line += 1;
-
                    l
-
                }
-
                Modification::Deletion(v) => {
-
                    let l = Modification::deletion(v.line, header.old_line_no + old_line);
-
                    old_line += 1;
-
                    l
-
                }
-
                Modification::Context { line, .. } => {
-
                    let l = Modification::Context {
-
                        line,
-
                        line_no_old: header.old_line_no + old_line,
-
                        line_no_new: header.new_line_no + new_line,
-
                    };
-
                    new_line += 1;
-
                    old_line += 1;
-
                    l
-
                }
-
            };
-

-
            lines.push(line);
-
        }
-

-
        Ok(Hunk {
-
            header: Line::from(header.to_unified_string()?),
-
            lines,
-
            old: header.old_line_range(),
-
            new: header.new_line_range(),
-
        })
-
    }
-
}
-

-
impl Encode for Hunk<Modification> {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        // TODO: Remove trailing newlines accurately.
-
        // `trim_end()` will destroy diff information if the diff has a trailing whitespace on
-
        // purpose.
-
        w.magenta(self.header.from_utf8_lossy().trim_end())?;
-
        for l in &self.lines {
-
            l.encode(w)?;
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
impl Decode for Modification {
-
    fn decode(r: &mut impl io::BufRead) -> Result<Self, Error> {
-
        let mut line = String::new();
-
        if r.read_line(&mut line)? == 0 {
-
            return Err(Error::UnexpectedEof);
-
        };
-

-
        let mut chars = line.chars();
-
        let l = match chars.next() {
-
            Some('+') => Modification::addition(chars.as_str().to_string(), 0),
-
            Some('-') => Modification::deletion(chars.as_str().to_string(), 0),
-
            Some(' ') => Modification::Context {
-
                line: chars.as_str().to_string().into(),
-
                line_no_old: 0,
-
                line_no_new: 0,
-
            },
-
            Some(c) => {
-
                return Err(Error::syntax(format!(
-
                    "indicator character expected, but got '{c}'",
-
                )))
-
            }
-
            None => return Err(Error::UnexpectedEof),
-
        };
-

-
        Ok(l)
-
    }
-
}
-

-
impl Encode for Modification {
-
    fn encode(&self, w: &mut Writer) -> Result<(), Error> {
-
        match self {
-
            Modification::Deletion(radicle_surf::diff::Deletion { line, .. }) => {
-
                let s = format!("-{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Red))?;
-
            }
-
            Modification::Addition(radicle_surf::diff::Addition { line, .. }) => {
-
                let s = format!("+{}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::new(term::Color::Green))?;
-
            }
-
            Modification::Context { line, .. } => {
-
                let s = format!(" {}", String::from_utf8_lossy(line.as_bytes()).trim_end());
-
                w.write(s, term::Style::default().dim())?;
-
            }
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
/// An IO Writer with color printing to the terminal.
-
pub struct Writer<'a> {
-
    styled: bool,
-
    stream: Box<dyn io::Write + 'a>,
-
}
-

-
impl<'a> Writer<'a> {
-
    pub fn new(w: impl io::Write + 'a) -> Self {
-
        Self {
-
            styled: false,
-
            stream: Box::new(w),
-
        }
-
    }
-

-
    pub fn encode<T: Encode>(&mut self, arg: &T) -> Result<(), Error> {
-
        arg.encode(self)?;
-
        Ok(())
-
    }
-

-
    pub fn styled(mut self, value: bool) -> Self {
-
        self.styled = value;
-
        self
-
    }
-

-
    pub fn write(&mut self, s: impl fmt::Display, style: term::Style) -> io::Result<()> {
-
        #[cfg(windows)]
-
        const EOL: &str = "\r\n";
-

-
        #[cfg(not(windows))]
-
        const EOL: &str = "\n";
-

-
        if self.styled {
-
            write!(
-
                self.stream,
-
                "{}{EOL}",
-
                term::Paint::new(s).with_style(style)
-
            )
-
        } else {
-
            write!(self.stream, "{s}{EOL}")
-
        }
-
    }
-

-
    pub fn meta(&mut self, s: impl fmt::Display) -> io::Result<()> {
-
        self.write(s, term::Style::new(term::Color::Yellow))
-
    }
-

-
    pub fn magenta(&mut self, s: impl fmt::Display) -> io::Result<()> {
-
        self.write(s, term::Style::new(term::Color::Magenta))
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use super::*;
-

-
    #[test]
-
    fn test_diff_encode_decode_diff() {
-
        let diff_a = diff::Diff::parse(include_str!(concat!(
-
            env!("CARGO_MANIFEST_DIR"),
-
            "/tests/data/diff.diff"
-
        )))
-
        .unwrap();
-
        assert_eq!(
-
            include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/diff.diff")),
-
            diff_a.to_unified_string().unwrap()
-
        );
-
    }
-

-
    #[test]
-
    fn test_diff_content_encode_decode_content() {
-
        let diff_content = diff::DiffContent::parse(include_str!(concat!(
-
            env!("CARGO_MANIFEST_DIR"),
-
            "/tests/data/diff_body.diff"
-
        )))
-
        .unwrap();
-
        assert_eq!(
-
            include_str!(concat!(
-
                env!("CARGO_MANIFEST_DIR"),
-
                "/tests/data/diff_body.diff"
-
            )),
-
            diff_content.to_unified_string().unwrap()
-
        );
-
    }
-

-
    // TODO: Test parsing a real diff from this repository.
-
}