Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
POC: removing radicle-surf dependency
◌ CI pending Fintan Halpenny committed 3 months ago
commit 0ac3efb8da70e1f1bd53f74d2741baab1e87bc6f
parent af3f07627b05dac4a68b102e2908da23b061a631
1 pending (1 total) View logs
9 files changed +28 -3172
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.
-
}