use crate::comments::model::{CommentStore, Thread};
use crate::diff::model::{Changeset, LineKind, Side};
use std::collections::HashSet;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use unicode_width::UnicodeWidthChar;
pub fn str_width(s: &str) -> usize {
s.chars().map(char_width).sum()
}
pub fn char_width(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
pub fn take_width(s: &str, max: usize) -> (String, usize) {
let mut out = String::new();
let mut w = 0;
for c in s.chars() {
let cw = char_width(c);
if w + cw > max {
break;
}
out.push(c);
w += cw;
}
(out, w)
}
fn fmt_date(t: SystemTime) -> String {
let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (hh, mm) = (tod / 3600, (tod % 3600) / 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + if m <= 2 { 1 } else { 0 };
format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
}
#[derive(Debug, Clone)]
pub enum CommentKind {
Top,
Head { replies: usize },
Author { name: String, date: String },
Body(String),
Gap,
Bottom,
}
#[derive(Debug, Clone)]
pub struct CommentLine {
pub kind: CommentKind,
pub resolved: bool,
pub thread_id: String,
pub comment_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum ComposerAnchor {
NewThread {
file_idx: usize,
side: Side,
line: u32,
},
Reply { thread_id: String },
}
#[derive(Debug, Clone)]
pub struct ComposerSpec {
pub anchor: ComposerAnchor,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone)]
pub enum ComposerKind {
Top { title: String },
Body(String),
Hint,
Bottom,
}
#[derive(Debug, Clone)]
pub struct ComposerLine {
pub kind: ComposerKind,
}
enum Injected {
Comment(CommentLine),
Composer(ComposerLine),
}
fn composer_lines(spec: &ComposerSpec, width: usize) -> Vec<ComposerLine> {
let mut out = vec![ComposerLine {
kind: ComposerKind::Top {
title: spec.title.clone(),
},
}];
for raw in spec.body.split('\n') {
let s = sanitize_line(raw);
if s.is_empty() {
out.push(ComposerLine {
kind: ComposerKind::Body(String::new()),
});
} else {
for wl in wrap_text(&s, width) {
out.push(ComposerLine {
kind: ComposerKind::Body(wl),
});
}
}
}
out.push(ComposerLine {
kind: ComposerKind::Hint,
});
out.push(ComposerLine {
kind: ComposerKind::Bottom,
});
out
}
fn new_thread_composer(
composer: Option<&ComposerSpec>,
emitted: &mut bool,
file_idx: usize,
side: Side,
line: u32,
width: usize,
) -> Vec<ComposerLine> {
if *emitted {
return Vec::new();
}
if let Some(spec) = composer {
if let ComposerAnchor::NewThread {
file_idx: f,
side: s,
line: l,
} = spec.anchor
{
if f == file_idx && s == side && l == line {
*emitted = true;
return composer_lines(spec, width);
}
}
}
Vec::new()
}
fn wrap_text(s: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = Vec::new();
let mut line = String::new();
let mut w = 0usize;
let push_overlong = |word: &str, out: &mut Vec<String>, line: &mut String, w: &mut usize| {
for ch in word.chars() {
let cw = char_width(ch);
if *w > 0 && *w + cw > width {
out.push(std::mem::take(line));
*w = 0;
}
line.push(ch);
*w += cw;
}
};
let push_word = |word: &str, out: &mut Vec<String>, line: &mut String, w: &mut usize| {
let ww = str_width(word);
if *w == 0 {
push_overlong(word, out, line, w);
} else if *w + 1 + ww <= width {
line.push(' ');
line.push_str(word);
*w += 1 + ww;
} else {
out.push(std::mem::take(line));
*w = 0;
push_overlong(word, out, line, w);
}
};
for word in s.split_whitespace() {
push_word(word, &mut out, &mut line, &mut w);
}
out.push(line);
out
}
pub fn thread_lines(t: &Thread, width: usize) -> Vec<CommentLine> {
let chrome = |kind: CommentKind| CommentLine {
kind,
resolved: t.resolved,
thread_id: t.id.clone(),
comment_id: None,
};
let content = |kind: CommentKind, cid: &str| CommentLine {
kind,
resolved: t.resolved,
thread_id: t.id.clone(),
comment_id: Some(cid.to_string()),
};
let mut out = vec![
chrome(CommentKind::Top),
chrome(CommentKind::Head {
replies: t.comments.len(),
}),
];
for (i, c) in t.comments.iter().enumerate() {
out.push(content(
CommentKind::Author {
name: c.author.clone().unwrap_or_else(|| "?".into()),
date: fmt_date(c.created_at),
},
&c.id,
));
for raw in c.body.split('\n') {
let s = sanitize_line(raw);
if s.is_empty() {
out.push(content(CommentKind::Body(String::new()), &c.id));
} else {
for wl in wrap_text(&s, width) {
out.push(content(CommentKind::Body(wl), &c.id));
}
}
}
if i + 1 < t.comments.len() {
out.push(content(CommentKind::Gap, &c.id));
}
}
out.push(chrome(CommentKind::Bottom));
out
}
type ThreadsByPath<'a> = std::collections::HashMap<&'a Path, Vec<usize>>;
fn threads_by_path(comments: &CommentStore) -> ThreadsByPath<'_> {
let mut map: ThreadsByPath<'_> = std::collections::HashMap::new();
for (i, t) in comments.threads.iter().enumerate() {
map.entry(t.file.as_path()).or_default().push(i);
}
map
}
#[allow(clippy::too_many_arguments)]
fn comment_rows_for(
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
emitted: &mut HashSet<String>,
path: &str,
anchors: &[(Side, u32)],
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<(Side, Injected)> {
let mut out = Vec::new();
let Some(indices) = by_path.get(Path::new(path)) else {
return out;
};
for &i in indices {
let t = &comments.threads[i];
if emitted.contains(&t.id) {
continue;
}
if anchors
.iter()
.any(|(s, l)| *s == t.side && t.range.contains(*l))
{
emitted.insert(t.id.clone());
out.extend(
thread_lines(t, width)
.into_iter()
.map(|cl| (t.side, Injected::Comment(cl))),
);
if let Some(spec) = composer {
if matches!(&spec.anchor, ComposerAnchor::Reply { thread_id } if *thread_id == t.id)
{
out.extend(
composer_lines(spec, width)
.into_iter()
.map(|cl| (t.side, Injected::Composer(cl))),
);
}
}
}
}
out
}
pub fn sanitize_line(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut col = 0usize;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\t' => {
let n = 4 - (col % 4);
out.extend(std::iter::repeat_n(' ', n));
col += n;
}
'\r' | '\n' => {}
'\u{1b}' => match chars.peek() {
Some('[') => {
chars.next();
while let Some(&p) = chars.peek() {
chars.next();
if ('@'..='~').contains(&p) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(&p) = chars.peek() {
chars.next();
if p == '\u{7}' {
break;
}
if p == '\u{1b}' {
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
}
}
_ => {}
},
c if c.is_control() => {}
c => {
out.push(c);
col += 1;
}
}
}
out
}
#[derive(Debug, Clone)]
pub enum RowKind {
FileHeader,
HunkHeader,
Line {
kind: LineKind,
old_line: Option<u32>,
new_line: Option<u32>,
},
Comment(CommentLine),
Composer(ComposerLine),
}
#[derive(Debug, Clone)]
pub struct Row {
pub file_idx: usize,
pub kind: RowKind,
pub text: String,
}
impl Row {
pub fn anchor(&self) -> Option<(Side, u32)> {
match &self.kind {
RowKind::Line {
kind,
old_line,
new_line,
} => match kind {
LineKind::Deletion => old_line.map(|l| (Side::Old, l)),
_ => new_line.map(|l| (Side::New, l)),
},
_ => None,
}
}
pub fn is_selectable(&self) -> bool {
matches!(self.kind, RowKind::Line { .. })
}
}
#[derive(Debug, Clone)]
pub struct SideCell {
pub kind: LineKind,
pub line: Option<u32>,
pub text: String,
}
#[derive(Debug, Clone)]
pub enum SplitRowKind {
FileHeader,
HunkHeader,
Pair {
left: Option<SideCell>,
right: Option<SideCell>,
},
Comment {
side: Side,
line: CommentLine,
},
Composer {
side: Side,
line: ComposerLine,
},
}
#[derive(Debug, Clone)]
pub struct SplitRow {
pub file_idx: usize,
pub kind: SplitRowKind,
pub text: String, }
impl SplitRow {
pub fn is_selectable(&self) -> bool {
matches!(self.kind, SplitRowKind::Pair { .. })
}
pub fn anchor(&self) -> Option<(Side, u32)> {
match &self.kind {
SplitRowKind::Pair { left, right } => right
.as_ref()
.and_then(|c| c.line.map(|l| (Side::New, l)))
.or_else(|| left.as_ref().and_then(|c| c.line.map(|l| (Side::Old, l)))),
_ => None,
}
}
}
fn hunk_header(hunk: &crate::diff::model::Hunk) -> String {
format!(
"@@ -{},{} +{},{} @@{}",
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
hunk.section
.as_ref()
.map(|s| format!(" {s}"))
.unwrap_or_default()
)
}
pub fn build_split_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<SplitRow> {
let mut rows = Vec::new();
let mut emitted: HashSet<String> = HashSet::new();
let mut composer_emitted = false;
let by_path = threads_by_path(comments);
for (fi, file) in changeset.files.iter().enumerate() {
let path = file.display_path();
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::FileHeader,
text: file.display_path().to_string(),
});
if file.is_binary {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::HunkHeader,
text: "Binary file".into(),
});
continue;
}
for hunk in &file.hunks {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::HunkHeader,
text: hunk_header(hunk),
});
let mut dels: Vec<SideCell> = Vec::new();
let mut adds: Vec<SideCell> = Vec::new();
for line in &hunk.lines {
let cell = SideCell {
kind: line.kind,
line: match line.kind {
LineKind::Deletion => line.old_line,
_ => line.new_line,
},
text: sanitize_line(&line.text),
};
match line.kind {
LineKind::Deletion => dels.push(cell),
LineKind::Addition => adds.push(cell),
LineKind::Context => {
flush_pairs(
fi,
&mut dels,
&mut adds,
&mut rows,
comments,
&by_path,
&mut emitted,
path,
width,
composer,
&mut composer_emitted,
);
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::Pair {
left: Some(SideCell {
kind: LineKind::Context,
line: line.old_line,
text: sanitize_line(&line.text),
}),
right: Some(cell),
},
text: String::new(),
});
let mut anchors = Vec::new();
if let Some(l) = line.old_line {
anchors.push((Side::Old, l));
}
if let Some(l) = line.new_line {
anchors.push((Side::New, l));
}
for (side, inj) in comment_rows_for(
comments,
&by_path,
&mut emitted,
path,
&anchors,
width,
composer,
) {
rows.push(SplitRow {
file_idx: fi,
kind: split_injected(side, inj),
text: String::new(),
});
}
for (side, l) in &anchors {
for cl in new_thread_composer(
composer,
&mut composer_emitted,
fi,
*side,
*l,
width,
) {
rows.push(SplitRow {
file_idx: fi,
kind: SplitRowKind::Composer {
side: *side,
line: cl,
},
text: String::new(),
});
}
}
}
}
}
flush_pairs(
fi,
&mut dels,
&mut adds,
&mut rows,
comments,
&by_path,
&mut emitted,
path,
width,
composer,
&mut composer_emitted,
);
}
for (side, inj) in
orphan_thread_rows(comments, &by_path, &mut emitted, path, width, composer)
{
rows.push(SplitRow {
file_idx: fi,
kind: split_injected(side, inj),
text: String::new(),
});
}
}
rows
}
fn split_injected(side: Side, inj: Injected) -> SplitRowKind {
match inj {
Injected::Comment(line) => SplitRowKind::Comment { side, line },
Injected::Composer(line) => SplitRowKind::Composer { side, line },
}
}
#[allow(clippy::too_many_arguments)]
fn flush_pairs(
file_idx: usize,
dels: &mut Vec<SideCell>,
adds: &mut Vec<SideCell>,
rows: &mut Vec<SplitRow>,
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
emitted: &mut HashSet<String>,
path: &str,
width: usize,
composer: Option<&ComposerSpec>,
composer_emitted: &mut bool,
) {
let n = dels.len().max(adds.len());
let mut di = dels.drain(..);
let mut ai = adds.drain(..);
for _ in 0..n {
let left = di.next();
let right = ai.next();
let mut anchors = Vec::new();
if let Some(l) = left.as_ref().and_then(|c| c.line) {
anchors.push((Side::Old, l));
}
if let Some(l) = right.as_ref().and_then(|c| c.line) {
anchors.push((Side::New, l));
}
rows.push(SplitRow {
file_idx,
kind: SplitRowKind::Pair { left, right },
text: String::new(),
});
for (side, inj) in
comment_rows_for(comments, by_path, emitted, path, &anchors, width, composer)
{
rows.push(SplitRow {
file_idx,
kind: split_injected(side, inj),
text: String::new(),
});
}
for (side, l) in &anchors {
for cl in new_thread_composer(composer, composer_emitted, file_idx, *side, *l, width) {
rows.push(SplitRow {
file_idx,
kind: SplitRowKind::Composer {
side: *side,
line: cl,
},
text: String::new(),
});
}
}
}
}
fn orphan_thread_rows(
comments: &CommentStore,
by_path: &ThreadsByPath<'_>,
emitted: &mut HashSet<String>,
path: &str,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<(Side, Injected)> {
let mut out = Vec::new();
let Some(indices) = by_path.get(Path::new(path)) else {
return out;
};
for &i in indices {
let t = &comments.threads[i];
if emitted.contains(t.id.as_str()) {
continue;
}
emitted.insert(t.id.clone());
out.extend(
thread_lines(t, width)
.into_iter()
.map(|cl| (t.side, Injected::Comment(cl))),
);
if let Some(spec) = composer {
if matches!(&spec.anchor, ComposerAnchor::Reply { thread_id } if *thread_id == t.id) {
out.extend(
composer_lines(spec, width)
.into_iter()
.map(|cl| (t.side, Injected::Composer(cl))),
);
}
}
}
out
}
pub fn build_rows(
changeset: &Changeset,
comments: &CommentStore,
width: usize,
composer: Option<&ComposerSpec>,
) -> Vec<Row> {
let mut rows = Vec::new();
let mut emitted: HashSet<String> = HashSet::new();
let mut composer_emitted = false;
let by_path = threads_by_path(comments);
for (fi, file) in changeset.files.iter().enumerate() {
let path = file.display_path();
rows.push(Row {
file_idx: fi,
kind: RowKind::FileHeader,
text: file.display_path().to_string(),
});
if file.is_binary {
rows.push(Row {
file_idx: fi,
kind: RowKind::HunkHeader,
text: "Binary file".into(),
});
continue;
}
for hunk in &file.hunks {
rows.push(Row {
file_idx: fi,
kind: RowKind::HunkHeader,
text: hunk_header(hunk),
});
for line in &hunk.lines {
let prefix = match line.kind {
LineKind::Addition => '+',
LineKind::Deletion => '-',
LineKind::Context => ' ',
};
rows.push(Row {
file_idx: fi,
kind: RowKind::Line {
kind: line.kind,
old_line: line.old_line,
new_line: line.new_line,
},
text: format!("{prefix}{}", sanitize_line(&line.text)),
});
let (side, ln) = match line.kind {
LineKind::Deletion => (Side::Old, line.old_line),
_ => (Side::New, line.new_line),
};
if let Some(ln) = ln {
for (_, inj) in comment_rows_for(
comments,
&by_path,
&mut emitted,
path,
&[(side, ln)],
width,
composer,
) {
let kind = match inj {
Injected::Comment(cl) => RowKind::Comment(cl),
Injected::Composer(cl) => RowKind::Composer(cl),
};
rows.push(Row {
file_idx: fi,
kind,
text: String::new(),
});
}
for cl in
new_thread_composer(composer, &mut composer_emitted, fi, side, ln, width)
{
rows.push(Row {
file_idx: fi,
kind: RowKind::Composer(cl),
text: String::new(),
});
}
}
}
}
for (_, inj) in orphan_thread_rows(comments, &by_path, &mut emitted, path, width, composer)
{
let kind = match inj {
Injected::Comment(cl) => RowKind::Comment(cl),
Injected::Composer(cl) => RowKind::Composer(cl),
};
rows.push(Row {
file_idx: fi,
kind,
text: String::new(),
});
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::comments::model::{CommentStore, LineRange};
use crate::diff::parse::parse_report;
use crate::loader::{load_comments, load_patch};
use std::path::{Path, PathBuf};
const SIMPLE_DIFF: &str = "\
--- a/foo.txt
+++ b/foo.txt
@@ -1,3 +1,3 @@ fn main
a
-b
+B
c
";
fn store_with(side: Side, line: u32) -> CommentStore {
let mut store = CommentStore::default();
store.add_thread(
PathBuf::from("foo.txt"),
side,
LineRange {
start: line,
end: line,
},
Some("me".into()),
"a comment".into(),
);
store
}
#[test]
fn split_comment_renders_under_anchored_side() {
let cs = parse_report(SIMPLE_DIFF).0;
let old = store_with(Side::Old, 2);
let rows = build_split_rows(&cs, &old, 80, None);
assert!(
rows.iter().any(|r| matches!(
r.kind,
SplitRowKind::Comment {
side: Side::Old,
..
}
)),
"old-side comment should be tagged Side::Old"
);
assert!(
!rows.iter().any(|r| matches!(
r.kind,
SplitRowKind::Comment {
side: Side::New,
..
}
)),
"old-side comment must not be tagged Side::New"
);
let new = store_with(Side::New, 2);
let rows = build_split_rows(&cs, &new, 80, None);
assert!(
rows.iter().any(|r| matches!(
r.kind,
SplitRowKind::Comment {
side: Side::New,
..
}
)),
"new-side comment should be tagged Side::New"
);
}
#[test]
fn thread_anchored_outside_any_hunk_is_still_emitted() {
let cs = parse_report(SIMPLE_DIFF).0;
let orphan = store_with(Side::New, 99);
let unified = build_rows(&cs, &orphan, 80, None);
assert!(
unified
.iter()
.any(|r| matches!(r.kind, RowKind::Comment(_))),
"an out-of-hunk thread must still render in the unified view"
);
let split = build_split_rows(&cs, &orphan, 80, None);
assert!(
split
.iter()
.any(|r| matches!(r.kind, SplitRowKind::Comment { .. })),
"an out-of-hunk thread must still render in the split view"
);
}
#[test]
fn in_hunk_thread_is_not_double_emitted_by_orphan_pass() {
let cs = parse_report(SIMPLE_DIFF).0;
let inhunk = store_with(Side::New, 2); let rows = build_rows(&cs, &inhunk, 80, None);
let box_tops = rows
.iter()
.filter(
|r| matches!(&r.kind, RowKind::Comment(cl) if matches!(cl.kind, CommentKind::Top)),
)
.count();
assert_eq!(box_tops, 1, "thread emitted exactly once");
}
#[test]
fn expands_tabs_and_strips_controls() {
assert_eq!(sanitize_line("\tx"), " x");
assert_eq!(sanitize_line("a\tb"), "a b"); assert_eq!(sanitize_line("end\r"), "end");
assert_eq!(sanitize_line("a\u{0}b"), "ab");
assert_eq!(sanitize_line("\u{1b}[31mred\u{1b}[0m"), "red");
}
#[test]
fn display_width_counts_wide_glyphs() {
assert_eq!(str_width("abc"), 3);
assert_eq!(str_width("日本語"), 6); assert_eq!(str_width("a日b"), 4);
}
#[test]
fn take_width_never_overflows_on_wide_glyphs() {
let (s, w) = take_width("a日本", 3);
assert_eq!(s, "aæ—¥");
assert_eq!(w, 3);
let (s, w) = take_width("日本", 1);
assert_eq!(s, "");
assert_eq!(w, 0);
}
#[test]
fn wrap_text_wraps_on_display_width() {
let lines = wrap_text("日本語", 4);
assert!(lines.iter().all(|l| str_width(l) <= 4));
assert_eq!(lines.concat(), "日本語");
}
#[test]
fn wrap_text_no_empty_lines_for_unsplittable_glyphs() {
let lines = wrap_text("日本", 1);
assert_eq!(lines, vec!["日".to_string(), "本".to_string()]);
assert!(lines.iter().all(|l| !l.is_empty()));
}
#[test]
fn injects_inline_thread_rows() {
let cs = load_patch(Some(Path::new("examples/rust-long-en.patch"))).unwrap();
let comments = load_comments(Path::new("examples/rust-long-en.comments.json")).unwrap();
let base = build_rows(&cs, &CommentStore::default(), 80, None);
let rows = build_rows(&cs, &comments, 80, None);
assert!(rows.len() > base.len());
let comment_rows = rows
.iter()
.filter(|r| matches!(r.kind, RowKind::Comment(_)))
.count();
assert!(comment_rows > 0);
let heads = rows
.iter()
.filter(|r| {
matches!(
&r.kind,
RowKind::Comment(CommentLine {
kind: CommentKind::Head { .. },
..
})
)
})
.count();
assert_eq!(heads, comments.threads.len());
assert!(rows.iter().all(|r| !matches!(r.kind, RowKind::Comment(_))
|| (!r.is_selectable() && r.anchor().is_none())));
}
#[test]
fn new_thread_composer_injects_inline_box() {
let cs = parse_report(SIMPLE_DIFF).0;
let store = CommentStore::default();
let spec = ComposerSpec {
anchor: ComposerAnchor::NewThread {
file_idx: 0,
side: Side::New,
line: 2,
},
title: " new comment ".into(),
body: "hi".into(),
};
assert!(!build_rows(&cs, &store, 80, None)
.iter()
.any(|r| matches!(r.kind, RowKind::Composer(_))));
let rows = build_rows(&cs, &store, 80, Some(&spec));
assert_eq!(
rows.iter()
.filter(|r| matches!(
r.kind,
RowKind::Composer(ComposerLine {
kind: ComposerKind::Top { .. }
})
))
.count(),
1
);
assert!(rows
.iter()
.any(|r| matches!(&r.kind, RowKind::Composer(ComposerLine { kind: ComposerKind::Body(b) }) if b.contains("hi"))));
assert!(rows
.iter()
.all(|r| !matches!(r.kind, RowKind::Composer(_)) || !r.is_selectable()));
}
#[test]
fn reply_composer_injects_under_its_thread() {
let cs = parse_report(SIMPLE_DIFF).0;
let store = store_with(Side::New, 2);
let thread_id = store.threads[0].id.clone();
let spec = ComposerSpec {
anchor: ComposerAnchor::Reply { thread_id },
title: " reply ".into(),
body: "ok".into(),
};
let rows = build_rows(&cs, &store, 80, Some(&spec));
let bottom = rows.iter().position(|r| {
matches!(
&r.kind,
RowKind::Comment(CommentLine {
kind: CommentKind::Bottom,
..
})
)
});
let comp_top = rows.iter().position(|r| {
matches!(
r.kind,
RowKind::Composer(ComposerLine {
kind: ComposerKind::Top { .. }
})
)
});
assert!(bottom.is_some() && comp_top.is_some());
assert!(
comp_top > bottom,
"reply composer must sit below its thread"
);
}
}