Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
radicle-tui bin ui items patch.rs
use std::collections::HashMap;
use std::fmt;
use std::fmt::Debug;
use std::ops::Range;

use ansi_to_tui::IntoText;

use radicle::cob::thread::Comment;
use radicle::cob::{CodeLocation, CodeRange, EntryId, Timestamp};
use radicle::git::Oid;
use radicle::patch::{PatchId, Review};
use radicle::prelude::Did;
use radicle::storage::git::Repository;
use radicle::storage::WriteRepository;
use radicle::Profile;

use radicle_surf::diff;
use radicle_surf::diff::{Hunk, Modification};

use radicle_cli::git::unified_diff::{Decode, HunkHeader};
use radicle_cli::terminal::highlight::Highlighter;

use ratatui::layout::Constraint;
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::Cell;

use radicle_tui as tui;

use tui::ui::span;
use tui::ui::utils::LineMerger;
use tui::ui::utils::MergeLocation;
use tui::ui::widget::Column;
use tui::ui::ToRow;

use crate::git::{self, Blobs, DiffStats, HunkDiff, HunkState, HunkStats};
use crate::ui;

use super::format;
use super::AuthorItem;

#[derive(Clone, Debug)]
pub struct Patch {
    /// Patch OID.
    pub id: PatchId,
    /// Patch state.
    pub state: radicle::patch::State,
    /// Patch title.
    pub title: String,
    /// Author of the latest revision.
    pub author: AuthorItem,
    /// Head of the latest revision.
    pub head: Oid,
    /// Lines added by the latest revision.
    pub added: Option<usize>,
    /// Lines removed by the latest revision.
    pub removed: Option<usize>,
    /// Time when patch was opened.
    pub timestamp: Timestamp,
}

impl Patch {
    pub fn new(
        profile: &Profile,
        repository: &Repository,
        patch: (PatchId, radicle::patch::Patch),
    ) -> Result<Self, anyhow::Error> {
        let (id, patch) = patch;
        let (_, revision) = patch.latest();
        let (from, to) = revision.range();
        let stats = git::diff_stats(repository.raw(), &from, &to)?;

        Ok(Self {
            id,
            state: patch.state().clone(),
            title: patch.title().into(),
            author: AuthorItem::new(Some(*patch.author().id), profile),
            head: revision.head(),
            added: Some(stats.insertions()),
            removed: Some(stats.deletions()),
            timestamp: patch.updated_at(),
        })
    }

    pub fn without_stats(
        profile: &Profile,
        patch: (PatchId, radicle::patch::Patch),
    ) -> Result<Self, anyhow::Error> {
        let (id, patch) = patch;
        let (_, revision) = patch.latest();

        Ok(Self {
            id,
            state: patch.state().clone(),
            title: patch.title().into(),
            author: AuthorItem::new(Some(*patch.author().id), profile),
            head: revision.head(),
            added: None,
            removed: None,
            timestamp: patch.updated_at(),
        })
    }
}

impl ToRow<9> for Patch {
    fn to_row(&self) -> [Cell<'_>; 9] {
        let (state, color) = format::patch_state(&self.state);

        let state = span::default(&state).style(Style::default().fg(color));
        let id = span::primary(&format::cob(&self.id));
        let title = span::default(&self.title.clone());

        let author = match &self.author.alias {
            Some(alias) => {
                if self.author.you {
                    span::alias(&format!("{alias} (you)"))
                } else {
                    span::alias(alias)
                }
            }
            None => match &self.author.human_nid {
                Some(nid) => span::alias(nid).dim(),
                None => span::blank(),
            },
        };
        let did = match &self.author.human_nid {
            Some(nid) => span::alias(nid).dim(),
            None => span::blank(),
        };

        let head = span::ternary(&format::oid(self.head));
        let added = span::positive(&format!(
            "+{}",
            self.added.map(|a| a.to_string()).unwrap_or("?".to_string())
        ));
        let removed = span::negative(&format!(
            "+{}",
            self.removed
                .map(|r| r.to_string())
                .unwrap_or("?".to_string())
        ));
        let updated = span::timestamp(&format::timestamp(&self.timestamp));

        [
            state.into(),
            id.into(),
            title.into(),
            author.into(),
            did.into(),
            head.into(),
            added.into(),
            removed.into(),
            updated.into(),
        ]
    }
}

pub mod filter {
    use std::fmt;
    use std::fmt::Debug;
    use std::fmt::Write as _;
    use std::str::FromStr;

    use nom::branch::alt;
    use nom::bytes::complete::{tag_no_case, take_while1};
    use nom::character::complete::multispace0;
    use nom::combinator::{map, value};
    use nom::multi::many0;
    use nom::sequence::preceded;
    use nom::IResult;

    use radicle::patch::Status;

    use crate::ui::items::filter;
    use crate::ui::items::filter::DidFilter;
    use crate::ui::items::filter::Filter;

    use super::Patch;

    #[derive(Clone, Debug, Eq, PartialEq)]
    pub enum PatchFilter {
        State(Status),
        Author(DidFilter),
        Search(String),
        And(Vec<PatchFilter>),
        Empty,
        Invalid,
    }

    impl Default for PatchFilter {
        fn default() -> Self {
            PatchFilter::State(Status::Open)
        }
    }

    impl PatchFilter {
        pub fn is_default(&self) -> bool {
            *self == PatchFilter::default()
        }

        pub fn has_state(&self) -> bool {
            match self {
                PatchFilter::State(_) => true,
                PatchFilter::And(filters) => {
                    filters.iter().any(|f| matches!(f, PatchFilter::State(_)))
                }
                _ => false,
            }
        }
    }

    impl fmt::Display for PatchFilter {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                PatchFilter::State(state) => {
                    write!(f, "state={state}")?;
                    f.write_char(' ')?;
                }
                PatchFilter::Author(filter) => {
                    write!(f, "author={filter}")?;
                    f.write_char(' ')?;
                }
                PatchFilter::Search(search) => {
                    write!(f, "{search}")?;
                    f.write_char(' ')?;
                }
                PatchFilter::And(filters) => {
                    let mut it = filters.iter().peekable();
                    while let Some(filter) = it.next() {
                        write!(f, "{filter}")?;
                        if it.peek().is_none() {
                            f.write_char(' ')?;
                        }
                    }
                }
                PatchFilter::Empty | PatchFilter::Invalid => {}
            }

            Ok(())
        }
    }

    impl Filter<Patch> for PatchFilter {
        fn matches(&self, patch: &Patch) -> bool {
            use fuzzy_matcher::skim::SkimMatcherV2;
            use fuzzy_matcher::FuzzyMatcher;

            let matcher = SkimMatcherV2::default();

            match self {
                PatchFilter::State(state) => Status::from(&patch.state) == *state,
                PatchFilter::Author(author_filter) => match author_filter {
                    DidFilter::Single(author) => patch.author.nid == Some(**author),
                    DidFilter::Or(authors) => authors
                        .iter()
                        .any(|other| patch.author.nid == Some(**other)),
                },
                PatchFilter::Search(search) => {
                    match matcher.fuzzy_match(
                        &format!(
                            "{} {} {}",
                            &patch.id.to_string(),
                            &patch.title,
                            &patch
                                .author
                                .alias
                                .as_ref()
                                .map(|a| a.to_string())
                                .unwrap_or_default()
                        ),
                        search,
                    ) {
                        Some(score) => score == 0 || score > filter::FUZZY_MIN_SCORE,
                        _ => false,
                    }
                }
                PatchFilter::And(filters) => filters.iter().all(|f| f.matches(patch)),
                PatchFilter::Empty => true,
                PatchFilter::Invalid => false,
            }
        }
    }

    impl FromStr for PatchFilter {
        type Err = anyhow::Error;

        fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
            use nom::Parser;

            fn parse_state(input: &str) -> IResult<&str, Status> {
                alt((
                    value(Status::Open, tag_no_case("open")),
                    value(Status::Merged, tag_no_case("merged")),
                    value(Status::Draft, tag_no_case("draft")),
                    value(Status::Archived, tag_no_case("archived")),
                ))
                .parse(input)
            }

            fn parse_state_filter(input: &str) -> IResult<&str, PatchFilter> {
                map(
                    preceded(
                        (
                            tag_no_case("state"),
                            multispace0,
                            tag_no_case("="),
                            multispace0,
                        ),
                        parse_state,
                    ),
                    PatchFilter::State,
                )
                .parse(input)
            }

            fn parse_author_filter(input: &str) -> IResult<&str, PatchFilter> {
                map(
                    preceded(
                        (
                            tag_no_case("author"),
                            multispace0,
                            tag_no_case("="),
                            multispace0,
                        ),
                        alt((filter::parse_did_single, filter::parse_did_or)),
                    ),
                    PatchFilter::Author,
                )
                .parse(input)
            }

            fn parse_search_filter(input: &str) -> IResult<&str, PatchFilter> {
                map(
                    take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
                    |s: &str| PatchFilter::Search(s.to_string()),
                )
                .parse(input)
            }

            fn parse_single_filter(input: &str) -> IResult<&str, PatchFilter> {
                alt((parse_state_filter, parse_author_filter, parse_search_filter)).parse(input)
            }

            fn parse_filters(input: &str) -> IResult<&str, Vec<PatchFilter>> {
                many0(preceded(multispace0, parse_single_filter)).parse(input)
            }

            let parse_filter_expression = |input: &str| -> Result<PatchFilter, String> {
                match parse_filters(input) {
                    Ok((remaining, filters)) => {
                        let remaining = remaining.trim();
                        if !remaining.is_empty() {
                            return Err(format!("Unparsed input remaining: '{remaining}'"));
                        }

                        if filters.is_empty() {
                            return Ok(PatchFilter::Empty);
                        }

                        if filters.len() == 1 {
                            Ok(filters.into_iter().next().unwrap())
                        } else {
                            Ok(PatchFilter::And(filters))
                        }
                    }
                    Err(e) => Err(format!("Parse error: {e}")),
                }
            };

            parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err))
        }
    }
}

pub struct TermLine(radicle_cli::terminal::Line);

impl From<TermLine> for Line<'_> {
    fn from(val: TermLine) -> Self {
        Line::raw(val.0.to_string())
    }
}

/// Represents the old and new ranges of a unified diff.
pub struct DiffLineRanges {
    old: Range<u32>,
    new: Range<u32>,
}

impl From<&Hunk<Modification>> for DiffLineRanges {
    fn from(hunk: &Hunk<Modification>) -> Self {
        Self {
            old: hunk.old.clone(),
            new: hunk.new.clone(),
        }
    }
}

/// Identifies a line in a unified diff by its old and new line number.
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
pub struct DiffLineIndex {
    old: Option<u32>,
    new: Option<u32>,
}

impl DiffLineIndex {
    pub fn is_start_of(&self, ranges: &DiffLineRanges) -> bool {
        // TODO(erikli): Find out, why comments inserted right before or after
        // the hunk header can have such weird values.
        let old = self
            .old
            .map(|o| self.new.is_none() && o >= 4294967294)
            .unwrap_or_default();
        let new = self
            .new
            .map(|n| n == u32::MAX.saturating_sub(1) || n == ranges.new.end)
            .unwrap_or_default();

        old || new
    }

    pub fn is_end_of(&self, ranges: &DiffLineRanges) -> bool {
        let old = self
            .old
            .map(|o| o == ranges.old.end.saturating_sub(1))
            .unwrap_or_default();
        let new = self
            .new
            .map(|n| n == ranges.new.end.saturating_sub(1))
            .unwrap_or_default();

        old || new
    }

    pub fn is_inside_of(&self, ranges: &DiffLineRanges) -> bool {
        let old = self
            .old
            .map(|o| o >= ranges.old.start && o < ranges.old.end.saturating_sub(1))
            .unwrap_or_default();
        let new = self
            .new
            .map(|n| n >= ranges.new.start && n < ranges.new.end.saturating_sub(1))
            .unwrap_or_default();

        old || new
    }
}

/// Mention hunk header
impl From<&CodeLocation> for DiffLineIndex {
    fn from(location: &CodeLocation) -> Self {
        Self {
            old: location.old.as_ref().map(|r| match r {
                CodeRange::Lines { range } => range.end.saturating_sub(1) as u32,
                CodeRange::Chars { line, range: _ } => line.saturating_sub(1) as u32,
            }),
            new: location.new.as_ref().map(|r| match r {
                CodeRange::Lines { range } => range.end.saturating_sub(1) as u32,
                CodeRange::Chars { line, range: _ } => line.saturating_sub(1) as u32,
            }),
        }
    }
}

/// A type that can map a line index to a line number in a unified diff.
#[derive(Debug)]
pub struct IndexedDiffLines {
    lines: HashMap<DiffLineIndex, u32>,
}

impl IndexedDiffLines {
    pub fn new(diff: &HunkDiff) -> Self {
        let mut indexed = HashMap::new();

        if let Some(hunk) = diff.hunk() {
            for (index, line) in hunk.lines.iter().enumerate() {
                let line_index = match line {
                    Modification::Addition(addition) => DiffLineIndex {
                        old: None,
                        new: Some(addition.line_no),
                    },
                    Modification::Deletion(deletion) => DiffLineIndex {
                        old: Some(deletion.line_no),
                        new: None,
                    },
                    Modification::Context {
                        line: _,
                        line_no_old,
                        line_no_new,
                    } => DiffLineIndex {
                        old: Some(*line_no_old),
                        new: Some(*line_no_new),
                    },
                };

                indexed.insert(line_index, index as u32);
            }
        }

        Self { lines: indexed }
    }

    pub fn line(&self, index: DiffLineIndex) -> Option<u32> {
        self.lines.get(&index).copied()
    }
}

/// All comments per hunk, indexed by their merge location: start, line or end.
#[derive(Clone, Debug)]
pub struct HunkComments {
    /// All comments. Can be unsorted.
    comments: HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>>,
}

impl HunkComments {
    pub fn new(diff: &HunkDiff, comments: Vec<(EntryId, Comment<CodeLocation>)>) -> Self {
        let mut line_comments: HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>> =
            HashMap::new();
        let indexed = IndexedDiffLines::new(diff);

        for comment in comments {
            let line = if let Some(location) = comment.1.location() {
                if let Some(hunk) = diff.hunk() {
                    let ranges = DiffLineRanges::from(hunk);
                    let index = DiffLineIndex::from(location);

                    if index.is_start_of(&ranges) {
                        MergeLocation::Start
                    } else if index.is_end_of(&ranges) {
                        MergeLocation::End
                    } else {
                        let mut line = indexed
                            .line(index.clone())
                            .map(|line| MergeLocation::Line(line as usize));

                        // TODO(erikli): Properly fix index lookup rules for addition:
                        // old line number need to be ignored.
                        if line.is_none() {
                            line = indexed
                                .line(DiffLineIndex { old: None, ..index })
                                .map(|line| MergeLocation::Line(line as usize))
                        }
                        line.unwrap_or_default()
                    }
                } else {
                    MergeLocation::Unknown
                }
            } else {
                MergeLocation::Unknown
            };

            if let Some(comments) = line_comments.get_mut(&line) {
                comments.push(comment.clone());
            } else {
                line_comments.insert(line, vec![comment.clone()]);
            }
        }

        Self {
            comments: line_comments,
        }
    }

    pub fn all(&self) -> &HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>> {
        &self.comments
    }

    pub fn is_empty(&self) -> bool {
        self.comments.is_empty()
    }

    pub fn len(&self) -> usize {
        self.comments.values().fold(0_usize, |mut count, comments| {
            count += comments.len();
            count
        })
    }
}

/// A [`HunkItem`] that can be rendered. Hunk items are indexed sequentially and
/// provide access to the underlying hunk type.
#[derive(Clone)]
pub struct HunkItem<'a> {
    /// The underlying hunk type and its current state (accepted / rejected).
    pub diff: HunkDiff,
    /// Raw or highlighted hunk lines. Highlighting is expensive and needs to be asynchronously.
    /// Therefor, a hunks' lines need to stored separately.
    pub lines: Blobs<Vec<Line<'a>>>,
    /// A hunks' comments, indexed by line.
    pub comments: HunkComments,
}

impl From<(&Repository, &Review, &HunkDiff)> for HunkItem<'_> {
    fn from(value: (&Repository, &Review, &HunkDiff)) -> Self {
        let (repo, review, item) = value;
        let hi = Highlighter::default();

        // TODO(erikli): Start with raw, non-highlighted lines and
        // move highlighting to separate task / thread, e.g. here:
        // `let lines = blobs.raw()`
        let blobs = item.clone().blobs(repo.raw());
        let lines = blobs.highlight(hi);

        // Filter comments and include them, if:
        // - comment has a code location
        // - comment path matches hunk path
        // - comment code location is inside hunk code range
        let comments = review
            .comments()
            .filter(|(_, comment)| {
                if let Some(location) = comment.location() {
                    if location.path == *item.path() {
                        if let Some(hunk) = item.hunk() {
                            let ranges = DiffLineRanges::from(hunk);
                            let index = DiffLineIndex::from(location);

                            log::debug!("Checking comment {comment:?} at {index:?}");

                            return index.is_start_of(&ranges)
                                || index.is_inside_of(&ranges)
                                || index.is_end_of(&ranges);
                        } else {
                            return true;
                        }
                    }
                }
                false
            })
            .map(|(id, comment)| (*id, comment.clone()))
            .collect::<Vec<_>>();

        Self {
            diff: item.clone(),
            lines,
            comments: HunkComments::new(item, comments),
        }
    }
}

impl ToRow<3> for StatefulHunkItem<'_> {
    fn to_row(&self) -> [Cell<'_>; 3] {
        let build_stats_spans = |stats: &DiffStats| -> Vec<Span<'_>> {
            let mut cell = vec![];
            let comments = &self.inner().comments;

            if !comments.is_empty() {
                cell.push(
                    span::default(&format!(" {} ", comments.len()))
                        .dim()
                        .reversed(),
                );
                cell.push(span::default(" "));
            }

            let (added, deleted) = match stats {
                DiffStats::Hunk(stats) => (stats.added(), stats.deleted()),
                DiffStats::File(stats) => (stats.additions, stats.deletions),
            };

            if added > 0 {
                cell.push(span::default(&format!("+{added}")).light_green().dim());
            }

            if added > 0 && deleted > 0 {
                cell.push(span::default(",").dim());
            }

            if deleted > 0 {
                cell.push(span::default(&format!("-{deleted}")).light_red().dim());
            }

            cell
        };

        match &self.inner().diff {
            HunkDiff::Added {
                path,
                header: _,
                new: _,
                hunk,
                _stats: _,
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" A ").bold().light_green().dim()].to_vec(),
                ]
                .concat();

                [
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
            HunkDiff::Modified {
                path,
                header: _,
                old: _,
                new: _,
                hunk,
                _stats: _,
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" M ").bold().light_yellow().dim()].to_vec(),
                ]
                .concat();

                [
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
            HunkDiff::Deleted {
                path,
                header: _,
                old: _,
                hunk,
                _stats: _,
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" D ").bold().light_red().dim()].to_vec(),
                ]
                .concat();

                [
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
            HunkDiff::Copied { copied } => {
                let stats = copied.diff.stats().copied().unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::File(stats)),
                    [span::default(" CP ").bold().light_blue().dim()].to_vec(),
                ]
                .concat();

                [
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(&copied.new_path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
            HunkDiff::Moved { moved } => {
                let stats = moved.diff.stats().copied().unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::File(stats)),
                    [span::default(" MV ").bold().light_blue().dim()].to_vec(),
                ]
                .concat();

                [
                    Line::from(ui::span::hunk_state(self.state()))
                        .right_aligned()
                        .into(),
                    Line::from(ui::span::pretty_path(&moved.new_path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
            HunkDiff::EofChanged {
                path,
                header: _,
                old: _,
                new: _,
                _eof: _,
            } => [
                Line::from(ui::span::hunk_state(self.state()))
                    .right_aligned()
                    .into(),
                Line::from(ui::span::pretty_path(path, false, false)).into(),
                Line::from(span::default("EOF ").light_blue())
                    .right_aligned()
                    .into(),
            ],
            HunkDiff::ModeChanged {
                path,
                header: _,
                old: _,
                new: _,
            } => [
                Line::from(ui::span::hunk_state(self.state()))
                    .right_aligned()
                    .into(),
                Line::from(ui::span::pretty_path(path, false, false)).into(),
                Line::from(span::default("FM ").light_blue())
                    .right_aligned()
                    .into(),
            ],
        }
    }
}

impl<'a> HunkItem<'a> {
    pub fn header(&self) -> Vec<Column<'a>> {
        let comment_tag = if !self.comments.is_empty() {
            let count = self.comments.len();
            if count == 1 {
                span::default(" 1 comment ").dim().reversed()
            } else {
                span::default(&format!(" {count} comments "))
                    .dim()
                    .reversed()
            }
        } else {
            span::blank()
        };

        match &self.diff {
            HunkDiff::Added {
                path,
                header: _,
                new: _,
                hunk: _,
                _stats: _,
            } => {
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        Line::from(
                            [
                                comment_tag,
                                span::default(" "),
                                span::default(" added ").light_green().dim().reversed(),
                            ]
                            .to_vec(),
                        )
                        .right_aligned(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }

            HunkDiff::Modified {
                path,
                header: _,
                old: _,
                new: _,
                hunk: _,
                _stats: _,
            } => {
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        Line::from(
                            [
                                comment_tag,
                                span::default(" "),
                                span::default(" modified ").light_yellow().dim().reversed(),
                            ]
                            .to_vec(),
                        )
                        .right_aligned(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }

            HunkDiff::Deleted {
                path,
                header: _,
                old: _,
                hunk: _,
                _stats: _,
            } => {
                let path = Line::from(ui::span::pretty_path(path, true, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        Line::from(
                            [
                                comment_tag,
                                span::default(" "),
                                span::default(" deleted ").light_red().dim().reversed(),
                            ]
                            .to_vec(),
                        )
                        .right_aligned(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }
            HunkDiff::Copied { copied } => {
                let path = Line::from(
                    [
                        ui::span::pretty_path(&copied.old_path, false, true),
                        [span::default(" -> ")].to_vec(),
                        ui::span::pretty_path(&copied.new_path, false, true),
                    ]
                    .concat()
                    .to_vec(),
                );
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        span::default(" copied ")
                            .light_blue()
                            .dim()
                            .reversed()
                            .into_right_aligned_line(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }
            HunkDiff::Moved { moved } => {
                let path = Line::from(
                    [
                        ui::span::pretty_path(&moved.old_path, false, true),
                        [span::default(" -> ")].to_vec(),
                        ui::span::pretty_path(&moved.new_path, false, true),
                    ]
                    .concat()
                    .to_vec(),
                );
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        span::default(" moved ")
                            .light_blue()
                            .dim()
                            .reversed()
                            .into_right_aligned_line(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }

            HunkDiff::EofChanged {
                path,
                header: _,
                old: _,
                new: _,
                _eof: _,
            } => {
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        span::default(" eof ")
                            .dim()
                            .reversed()
                            .into_right_aligned_line(),
                        Constraint::Fill(1),
                    ),
                ];

                header.to_vec()
            }
            HunkDiff::ModeChanged {
                path,
                header: _,
                old: _,
                new: _,
            } => {
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
                    Column::new(
                        span::default(" mode ")
                            .dim()
                            .reversed()
                            .into_right_aligned_line(),
                        Constraint::Length(6),
                    ),
                ];

                header.to_vec()
            }
        }
    }

    pub fn hunk_text(&'a self) -> Option<Text<'a>> {
        match &self.diff {
            HunkDiff::Added { hunk, .. }
            | HunkDiff::Modified { hunk, .. }
            | HunkDiff::Deleted { hunk, .. } => {
                let mut lines = hunk
                    .as_ref()
                    .map(|hunk| Text::from(hunk.to_text(&self.lines)));

                lines = lines.map(|lines| {
                    let divider = span::default(&"─".to_string().repeat(500)).gray().dim();

                    let mut merge = HashMap::new();
                    for (line, comments) in self.comments.all() {
                        merge.insert(
                            line.clone(),
                            comments
                                .iter()
                                .enumerate()
                                .map(|(idx, comment)| {
                                    let timestamp =
                                        span::timestamp(&format::timestamp(&comment.1.timestamp()));
                                    let author =
                                        span::alias(&format::did(&Did::from(comment.1.author())));

                                    let mut rendered = vec![];

                                    // Only add top divider for the first comment
                                    if idx == 0 {
                                        rendered.push(Line::from([divider.clone()].to_vec()));
                                    }

                                    // Add comment body
                                    rendered.extend(comment.1.body().lines().map(|line| {
                                        Line::from([span::default(line).gray()].to_vec())
                                    }));

                                    // Add metadata
                                    rendered.push(
                                        Line::from(
                                            [timestamp, span::default(" by ").dim(), author]
                                                .to_vec(),
                                        )
                                        .right_aligned(),
                                    );

                                    // Add bottom divider
                                    rendered.push(Line::from([divider.clone()].to_vec()));

                                    rendered
                                })
                                .collect(),
                        );
                    }
                    let merged = LineMerger::new(lines.lines).merge(merge, None);
                    Text::from(merged)
                });

                lines
            }
            _ => None,
        }
    }
}

impl Debug for HunkItem<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("HunkItem")
            .field("inner", &self.diff)
            .field("comments", &self.comments)
            .finish()
    }
}

#[derive(Clone, Debug)]
pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState);

impl<'a> StatefulHunkItem<'a> {
    pub fn new(inner: HunkItem<'a>, state: HunkState) -> Self {
        Self(inner, state)
    }

    pub fn inner(&self) -> &HunkItem<'a> {
        &self.0
    }

    pub fn state(&self) -> &HunkState {
        &self.1
    }

    pub fn update_state(&mut self, state: &HunkState) {
        self.1 = state.clone();
    }
}

pub struct HighlightedLine<'a>(Line<'a>);

impl<'a> From<Line<'a>> for HighlightedLine<'a> {
    fn from(highlighted: Line<'a>) -> Self {
        let converted = highlighted.to_string().into_text().unwrap().lines;

        Self(converted.first().cloned().unwrap_or_default())
    }
}

impl<'a> From<HighlightedLine<'a>> for Line<'a> {
    fn from(val: HighlightedLine<'a>) -> Self {
        val.0
    }
}

/// Types that can be rendered as texts.
pub trait ToText<'a> {
    /// The output of the render process.
    type Output: Into<Text<'a>>;
    /// Context that can be passed down from parent objects during rendering.
    type Context;

    /// Render to pretty diff output.
    fn to_text(&'a self, context: &Self::Context) -> Self::Output;
}

impl<'a> ToText<'a> for HunkHeader {
    type Output = Line<'a>;
    type Context = ();

    fn to_text(&self, _context: &Self::Context) -> Self::Output {
        Line::from(
            [
                span::default(&format!(
                    "@@ -{},{} +{},{} @@",
                    self.old_line_no, self.old_size, self.new_line_no, self.new_size,
                ))
                .gray(),
                span::default(" "),
                span::default(String::from_utf8_lossy(&self.text).as_ref()),
            ]
            .to_vec(),
        )
    }
}

impl<'a> ToText<'a> for Modification {
    type Output = Line<'a>;
    type Context = Blobs<Vec<Line<'a>>>;

    fn to_text(&'a self, blobs: &Blobs<Vec<Line<'a>>>) -> Self::Output {
        let line = 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 {
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
                }
            }
            Modification::Addition(diff::Addition { line, line_no }) => {
                if let Some(lines) = &blobs.new.as_ref() {
                    lines[*line_no as usize - 1].clone()
                } else {
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
                }
            }
            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 {
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
                }
            }
        };

        HighlightedLine::from(line).into()
    }
}

impl<'a> ToText<'a> for Hunk<Modification> {
    type Output = Vec<Line<'a>>;
    type Context = Blobs<Vec<Line<'a>>>;

    fn to_text(&'a self, blobs: &Self::Context) -> Self::Output {
        let mut lines: Vec<Line<'a>> = vec![];

        let default_dark = Color::Rgb(20, 20, 20);

        let positive_light = Color::Rgb(10, 60, 20);
        let positive_dark = Color::Rgb(10, 30, 20);

        let negative_light = Color::Rgb(60, 10, 20);
        let negative_dark = Color::Rgb(30, 10, 20);

        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
            lines.push(Line::from(
                [
                    span::default(&format!(
                        "@@ -{},{} +{},{} @@",
                        header.old_line_no, header.old_size, header.new_line_no, header.new_size,
                    ))
                    .gray()
                    .dim(),
                    span::default(" "),
                    span::default(String::from_utf8_lossy(&header.text).as_ref())
                        .gray()
                        .dim(),
                ]
                .to_vec(),
            ))
        }

        for line in &self.lines {
            match line {
                Modification::Addition(a) => {
                    lines.push(Line::from(
                        [
                            [
                                span::positive(&format!("{:<5}", ""))
                                    .bg(positive_light)
                                    .dim(),
                                span::positive(&format!("{:<5}", &a.line_no.to_string()))
                                    .bg(positive_light)
                                    .dim(),
                                span::positive(" + ").bg(positive_dark).dim(),
                            ]
                            .to_vec(),
                            line.to_text(blobs)
                                .spans
                                .into_iter()
                                .map(|span| span.bg(positive_dark))
                                .collect::<Vec<_>>(),
                            [span::positive(&format!("{:<500}", "")).bg(positive_dark)].to_vec(),
                        ]
                        .concat(),
                    ));
                }
                Modification::Deletion(d) => {
                    lines.push(Line::from(
                        [
                            [
                                span::negative(&format!("{:<5}", &d.line_no.to_string()))
                                    .bg(negative_light)
                                    .dim(),
                                span::negative(&format!("{:<5}", ""))
                                    .bg(negative_light)
                                    .dim(),
                                span::negative(" - ").bg(negative_dark).dim(),
                            ]
                            .to_vec(),
                            line.to_text(blobs)
                                .spans
                                .into_iter()
                                .map(|span| span.bg(negative_dark))
                                .collect::<Vec<_>>(),
                            [span::positive(&format!("{:<500}", "")).bg(negative_dark)].to_vec(),
                        ]
                        .concat(),
                    ));
                }
                Modification::Context {
                    line_no_old,
                    line_no_new,
                    ..
                } => {
                    lines.push(Line::from(
                        [
                            [
                                span::default(&format!("{:<5}", &line_no_old.to_string()))
                                    .bg(default_dark)
                                    .gray()
                                    .dim(),
                                span::default(&format!("{:<5}", &line_no_new.to_string()))
                                    .bg(default_dark)
                                    .gray()
                                    .dim(),
                                span::default(&format!("{:<3}", "")),
                            ]
                            .to_vec(),
                            line.to_text(blobs).spans,
                        ]
                        .concat(),
                    ));
                }
            }
        }
        lines
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::str::FromStr;

    use anyhow::Result;

    use radicle::cob::patch;

    use crate::test;
    use crate::ui::items::filter::DidFilter;
    use crate::ui::items::patch::filter::PatchFilter;

    use super::*;

    #[test]
    fn patch_filter_with_all_should_succeed() -> Result<()> {
        let search = r#"state=open author=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) cli"#;
        let expected = PatchFilter::And(vec![
            PatchFilter::State(patch::Status::Open),
            PatchFilter::Author(DidFilter::Or(vec![
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
            ])),
            PatchFilter::Search("cli".to_string()),
        ]);
        let actual = PatchFilter::from_str(search)?;

        assert_eq!(expected, actual);

        Ok(())
    }

    #[test]
    fn patch_filter_with_all_shuffled_should_succeed() -> Result<()> {
        let search =
            r#"author=did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB state=open cli"#;
        let expected = PatchFilter::And(vec![
            PatchFilter::Author(DidFilter::Single(Did::from_str(
                "did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB",
            )?)),
            PatchFilter::State(patch::Status::Open),
            PatchFilter::Search("cli".to_string()),
        ]);
        let actual = PatchFilter::from_str(search)?;

        assert_eq!(expected, actual);

        Ok(())
    }

    #[test]
    fn diff_line_index_checks_ranges_correctly() -> Result<()> {
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();

        // --------------------------------------------------------------------
        // At the top.
        // --------------------------------------------------------------------
        // @@ -3,8 +3,7 @@
        // 3   3     // or if you prefer to use your keyboard, you can use the "Ctrl + Enter"
        // 4   4     // shortcut.
        // 5   5
        // 6       - // This code is editable, feel free to hack it!
        // 7       - // You can always return to the original code by clicking the "Reset" button ->
        //     6   + // This is still a comment.
        // --------------------------------------------------------------------
        // In the middle.
        // --------------------------------------------------------------------
        // 8   7
        // 9   8     // This is the main function.
        // 10  9     fn main() {
        // ---------------------------------------------------------------------
        // At the end.
        // ---------------------------------------------------------------------
        let diff = test::fixtures::simple_modified_hunk_diff(&path, commit)?;
        let ranges = DiffLineRanges::from(diff.hunk().unwrap());

        let start = CodeLocation {
            commit,
            path: path.clone(),
            old: Some(CodeRange::Lines { range: 3..12 }),
            new: Some(CodeRange::Lines { range: 3..11 }),
        };
        assert!(DiffLineIndex::from(&start).is_start_of(&ranges));
        assert!(!DiffLineIndex::from(&start).is_inside_of(&ranges));
        assert!(!DiffLineIndex::from(&start).is_end_of(&ranges));

        let inside = CodeLocation {
            commit,
            path: path.clone(),
            old: Some(CodeRange::Lines { range: 3..8 }),
            new: Some(CodeRange::Lines { range: 3..7 }),
        };
        assert!(DiffLineIndex::from(&inside).is_inside_of(&ranges));
        assert!(!DiffLineIndex::from(&inside).is_start_of(&ranges));
        assert!(!DiffLineIndex::from(&inside).is_end_of(&ranges));

        let end = CodeLocation {
            commit,
            path: path.clone(),
            old: Some(CodeRange::Lines { range: 3..11 }),
            new: Some(CodeRange::Lines { range: 3..10 }),
        };
        assert!(DiffLineIndex::from(&end).is_end_of(&ranges));
        assert!(!DiffLineIndex::from(&end).is_start_of(&ranges));
        assert!(!DiffLineIndex::from(&end).is_inside_of(&ranges));

        let outside = CodeLocation {
            commit,
            path: path.clone(),
            old: Some(CodeRange::Lines { range: 125..127 }),
            new: Some(CodeRange::Lines { range: 125..128 }),
        };
        assert!(!DiffLineIndex::from(&outside).is_start_of(&ranges));
        assert!(!DiffLineIndex::from(&outside).is_inside_of(&ranges));
        assert!(!DiffLineIndex::from(&outside).is_end_of(&ranges));

        Ok(())
    }

    #[test]
    fn hunk_comments_on_modified_simple_are_inserted_correctly() -> Result<()> {
        let alice = test::fixtures::node_with_repo();

        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();

        // --------------------------------------------------------------------
        // At the top.
        // --------------------------------------------------------------------
        // @@ -3,8 +3,7 @@
        // 3   3     // or if you prefer to use your keyboard, you can use the "Ctrl + Enter"
        // 4   4     // shortcut.
        // 5   5
        // 6       - // This code is editable, feel free to hack it!
        // 7       - // You can always return to the original code by clicking the "Reset" button ->
        //     6   + // This is still a comment.
        // --------------------------------------------------------------------
        // In the middle.
        // --------------------------------------------------------------------
        // 8   7
        // 9   8     // This is the main function.
        // 10  9     fn main() {
        // ---------------------------------------------------------------------
        // At the end.
        // ---------------------------------------------------------------------
        let diff = test::fixtures::simple_modified_hunk_diff(&path, commit)?;

        let top = (
            Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 3..12 }),
                    new: Some(CodeRange::Lines { range: 3..11 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );
        let middle = (
            Oid::from_str("2d09104bf2d6ad328aa72594b679d2d6c5a61865").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "In the middle.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 3..8 }),
                    new: Some(CodeRange::Lines { range: 3..7 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );
        let end = (
            Oid::from_str("8280317b308ba1bf2cef04533efb15d920431e86").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "At the end.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 3..11 }),
                    new: Some(CodeRange::Lines { range: 3..10 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );

        let comments = {
            let comments = [top.clone(), middle.clone(), end.clone()];
            HunkComments::new(&diff, comments.to_vec())
        };

        for expected in [
            (top, MergeLocation::Start),
            (middle, MergeLocation::Line(5)),
            (end, MergeLocation::End),
        ] {
            let (line, expected) = (expected.1, expected.0);
            let actual = comments.all().get(&line);
            assert_ne!(actual, None, "No comment found at {line:?}");

            let actual = actual.unwrap().first().unwrap();
            assert_eq!(actual.0, expected.0);
        }

        Ok(())
    }

    #[test]
    fn hunk_comments_on_modified_complex_are_inserted_correctly() -> Result<()> {
        let alice = test::fixtures::node_with_repo();

        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();

        // --------------------------------------------------------------------
        // At the top.
        // --------------------------------------------------------------------
        // @@ -1,17 +1,15 @@
        // 1       - use radicle::issue::IssueId;
        // 2       - use tui::ui::state::ItemState;
        // 3       - use tui::SelectionExit;
        // --------------------------------------------------------------------
        // After deletion.
        // --------------------------------------------------------------------
        // 4   1     use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
        // 5   2     use tuirealm::event::{Event, Key, KeyEvent};
        // 6   3     use tuirealm::{MockComponent, NoUserEvent};
        // 7   4
        // 8   5     use radicle_tui as tui;
        // 9   6
        //     7   + use tui::ui::state::ItemState;
        // 10  8     use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
        // 11  9     use tui::ui::widget::context::{ContextBar, Shortcuts};
        // 12  10    use tui::ui::widget::list::PropertyList;
        // 13      -
        // 14  11    use tui::ui::widget::Widget;
        //     12  + use tui::{Id, SelectionExit};
        // 15  13
        // 16  14    use super::ui::{IdSelect, OperationSelect};
        // --------------------------------------------------------------------
        // Before last line.
        // --------------------------------------------------------------------
        // 17  15    use super::{IssueOperation, Message};
        let diff = test::fixtures::complex_modified_hunk_diff(&path, commit)?;

        let top = (
            Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 1..18 }),
                    new: Some(CodeRange::Lines { range: 1..17 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );
        let after_deletion = (
            Oid::from_str("2d09104bf2d6ad328aa72594b679d2d6c5a61865").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "After deletion.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 1..4 }),
                    new: None,
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );
        let before_last_line = (
            Oid::from_str("60972bca0c9e686e76b0a5123acb3c8c60c38b1e").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "Before last line".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 1..17 }),
                    new: Some(CodeRange::Lines { range: 1..15 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );

        let comments = {
            let comments = [
                top.clone(),
                after_deletion.clone(),
                before_last_line.clone(),
            ];
            HunkComments::new(&diff, comments.to_vec())
        };

        for expected in [
            (top, MergeLocation::Start),
            (after_deletion, MergeLocation::Line(2)),
            (before_last_line, MergeLocation::Line(17)),
        ] {
            let (line, expected) = (expected.1, expected.0);
            let actual = comments.all().get(&line);
            assert_ne!(actual, None, "No comment found at {line:?}");

            let actual = actual.unwrap().first().unwrap();
            assert_eq!(actual.0, expected.0);
        }

        Ok(())
    }

    #[test]
    fn hunk_comments_on_deleted_simple_are_inserted_correctly() -> Result<()> {
        let alice = test::fixtures::node_with_repo();

        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("README.md").unwrap();

        // --------------------------------------------------------------------
        // At the top.
        // --------------------------------------------------------------------
        // @@ -1,1 +0,0 @@
        //  -TBD
        // --------------------------------------------------------------------
        // At the end.
        // --------------------------------------------------------------------
        let diff = test::fixtures::deleted_hunk_diff(&path, commit)?;

        let top = (
            Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 1..3 }),
                    new: Some(CodeRange::Lines { range: 0..1 }),
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );
        let end = (
            Oid::from_str("8280317b308ba1bf2cef04533efb15d920431e86").unwrap(),
            Comment::new(
                *alice.node.signer.public_key(),
                "At the end.".to_string(),
                None,
                Some(CodeLocation {
                    commit,
                    path: path.clone(),
                    old: Some(CodeRange::Lines { range: 1..2 }),
                    new: None,
                }),
                vec![],
                Timestamp::from_secs(0),
            ),
        );

        let comments = {
            let comments = [top.clone(), end.clone()];
            HunkComments::new(&diff, comments.to_vec())
        };

        for expected in [(top, MergeLocation::Start), (end, MergeLocation::End)] {
            let (line, expected) = (expected.1, expected.0);
            let actual = comments.all().get(&line);
            assert_ne!(actual, None, "No comment found at {line:?}");

            let actual = actual.unwrap().first().unwrap();
            assert_eq!(actual.0, expected.0);
        }

        Ok(())
    }
}