Skip to main content

aft/github_read/
render.rs

1use std::cmp::Ordering;
2
3use super::bot_compress::{compress_discussion_body, compress_document_body, structural_strip};
4use super::fetch::GithubReadError;
5use super::model::{
6    GithubComment, GithubDocument, GithubDocumentKind, GithubReaction, GithubReview,
7    GithubReviewCommentSection, GithubTimelineEvent,
8};
9use super::resource::{GithubCommentSelector, GithubResource};
10
11/// The maximum number of newest comments rendered for an issue or one pull
12/// request review-comment section. Older comments remain available on GitHub
13/// and are disclosed in the canonical document instead of silently dropped.
14pub const MAX_RENDERED_COMMENTS_PER_SECTION: usize = 50;
15
16/// Render the complete, transport-independent GitHub document once in Rust.
17///
18/// Selector handling belongs after this function. The renderer purposefully
19/// never sees a vision capability or an attachment downloader, so its bytes are
20/// identical for text-only and vision-capable callers.
21pub fn render_document(document: &GithubDocument) -> String {
22    let resource = GithubResource {
23        kind: document.resource_kind(),
24        number: document.number,
25        repository: Some(document.repository.clone()),
26        comment_selector: None,
27    };
28    render_document_for_resource(document, &resource)
29        .expect("a whole-document render has no fallible selector")
30}
31
32pub(super) fn render_document_for_resource(
33    document: &GithubDocument,
34    resource: &GithubResource,
35) -> Result<String, GithubReadError> {
36    if let Some(selector) = &resource.comment_selector {
37        return render_selected_discussion(document, selector);
38    }
39
40    let mut output = String::new();
41    let resource_label = match document.kind {
42        GithubDocumentKind::Issue => "Issue",
43        GithubDocumentKind::PullRequest => "Pull request",
44    };
45    output.push_str(&format!(
46        "# {resource_label} #{}: {}\n\n",
47        document.number, document.title
48    ));
49    output.push_str(&format!("Repository: {}\n", document.repository));
50    output.push_str(&format!("State: {}\n", document.state));
51    if let Some(author) = nonempty(&document.author) {
52        output.push_str(&format!("Author: {}\n", format_author(author)));
53    }
54    if let Some(created_at) = nonempty(&document.created_at) {
55        output.push_str(&format!("Created: {created_at}\n"));
56    }
57    if let Some(updated_at) = nonempty(&document.updated_at) {
58        output.push_str(&format!("Updated: {updated_at}\n"));
59    }
60    if !document.labels.is_empty() {
61        output.push_str(&format!("Labels: {}\n", document.labels.join(", ")));
62    }
63    if !document.assignees.is_empty() {
64        output.push_str(&format!(
65            "Assignees: {}\n",
66            document
67                .assignees
68                .iter()
69                .map(|assignee| format_author(assignee))
70                .collect::<Vec<_>>()
71                .join(", ")
72        ));
73    }
74    if let Some(milestone) = nonempty(&document.milestone) {
75        output.push_str(&format!("Milestone: {milestone}\n"));
76    }
77    if let Some(reactions) = render_reactions(&document.reactions) {
78        output.push_str(&format!("Reactions: {reactions}\n"));
79    }
80
81    output.push_str("\n## Body\n\n");
82    append_body(&mut output, &compress_document_body(&document.body).body);
83
84    let discussion = discussion_items(document);
85    render_comments(
86        &mut output,
87        "Comments",
88        &document.comments,
89        document.comments_total_count,
90        document.minimized_comments_count,
91        &discussion,
92        resource,
93    );
94
95    if document.kind == GithubDocumentKind::PullRequest {
96        render_files(&mut output, document);
97        render_reviews(&mut output, document, &discussion, resource);
98        render_review_comment_sections(
99            &mut output,
100            &document.review_comment_sections,
101            &discussion,
102            resource,
103        );
104    }
105    render_timeline(&mut output, &discussion);
106
107    output.push_str(&format!(
108        "Discussion drill-down: {}/comments/<sel> (for example 3, 3-5, 3,7, or -1).\n\n",
109        resource.base_spelling()
110    ));
111    Ok(output)
112}
113
114fn render_comments(
115    output: &mut String,
116    heading: &str,
117    comments: &[GithubComment],
118    total_count: Option<usize>,
119    supplied_minimized_count: Option<usize>,
120    discussion: &[DiscussionItem<'_>],
121    resource: &GithubResource,
122) {
123    if comments.is_empty()
124        && total_count.unwrap_or(0) == 0
125        && supplied_minimized_count.unwrap_or(0) == 0
126    {
127        return;
128    }
129    output.push_str(&format!("\n## {heading}\n\n"));
130    let displayed = newest_comments(comments);
131    let total_count = total_count.unwrap_or(comments.len()).max(comments.len());
132    let omitted = total_count.saturating_sub(displayed.len());
133    if omitted > 0 {
134        output.push_str(&format!("{omitted} earlier comments omitted\n\n"));
135    }
136    for comment in displayed {
137        if let Some(ordinal) = discussion_ordinal_for_comment(discussion, comment, false) {
138            render_default_discussion_item(
139                output,
140                ordinal,
141                comment.author.as_deref(),
142                comment.created_at.as_deref(),
143                None,
144                &comment.body,
145                resource,
146            );
147        }
148    }
149    let observed_minimized = comments.iter().filter(|comment| comment.minimized).count();
150    let minimized = supplied_minimized_count
151        .unwrap_or(observed_minimized)
152        .max(observed_minimized);
153    if minimized > 0 {
154        output.push_str(&format!("Minimized comments: {minimized}\n\n"));
155    }
156}
157
158fn render_files(output: &mut String, document: &GithubDocument) {
159    if document.files.is_empty() {
160        return;
161    }
162    output.push_str("\n## Files\n\n");
163    for file in &document.files {
164        output.push_str("- `");
165        output.push_str(&file.path);
166        output.push('`');
167        if file.additions.is_some() || file.deletions.is_some() {
168            output.push_str(&format!(
169                " (+{} -{})",
170                file.additions.unwrap_or(0),
171                file.deletions.unwrap_or(0)
172            ));
173        }
174        if let Some(status) = nonempty(&file.status) {
175            output.push_str(&format!(" [{status}]"));
176        }
177        output.push('\n');
178    }
179}
180
181fn render_reviews(
182    output: &mut String,
183    document: &GithubDocument,
184    discussion: &[DiscussionItem<'_>],
185    resource: &GithubResource,
186) {
187    if !document.reviews.iter().any(review_is_visible) {
188        return;
189    }
190    output.push_str("\n## Reviews\n\n");
191    for review in &document.reviews {
192        let Some(ordinal) = discussion_ordinal_for_review(discussion, review) else {
193            continue;
194        };
195        render_default_discussion_item(
196            output,
197            ordinal,
198            review.author.as_deref(),
199            review.submitted_at.as_deref(),
200            review.state.as_deref(),
201            &review.body,
202            resource,
203        );
204    }
205}
206
207fn render_review_comment_sections(
208    output: &mut String,
209    sections: &[GithubReviewCommentSection],
210    discussion: &[DiscussionItem<'_>],
211    resource: &GithubResource,
212) {
213    if !sections.iter().any(|section| {
214        section.comments.iter().any(comment_is_visible)
215            || section.comments_total_count.unwrap_or(0) > section.comments.len()
216            || section.minimized_comments_count.unwrap_or(0) > 0
217    }) {
218        return;
219    }
220    output.push_str("\n## Review comments\n\n");
221    for section in sections {
222        let displayed = newest_comments(&section.comments);
223        let total_count = section
224            .comments_total_count
225            .unwrap_or(section.comments.len())
226            .max(section.comments.len());
227        let omitted = total_count.saturating_sub(displayed.len());
228        if omitted > 0 {
229            let author = section
230                .author
231                .as_deref()
232                .map(format_author)
233                .unwrap_or_else(|| "unknown".to_string());
234            output.push_str(&format!(
235                "{omitted} earlier comments omitted from {author}'s review\n\n"
236            ));
237        }
238        for comment in displayed {
239            if let Some(ordinal) = discussion_ordinal_for_comment(discussion, comment, true) {
240                render_default_discussion_item(
241                    output,
242                    ordinal,
243                    comment.author.as_deref(),
244                    comment.created_at.as_deref(),
245                    None,
246                    &comment.body,
247                    resource,
248                );
249            }
250        }
251        let observed_minimized = section
252            .comments
253            .iter()
254            .filter(|comment| comment.minimized)
255            .count();
256        let minimized = section
257            .minimized_comments_count
258            .unwrap_or(observed_minimized)
259            .max(observed_minimized);
260        if minimized > 0 {
261            output.push_str(&format!("Minimized comments: {minimized}\n\n"));
262        }
263    }
264}
265
266fn render_timeline(output: &mut String, discussion: &[DiscussionItem<'_>]) {
267    let events = discussion
268        .iter()
269        .enumerate()
270        .filter_map(|(index, item)| item.event().map(|event| (index + 1, event)));
271    let mut rendered_any = false;
272    for (ordinal, event) in events {
273        if !rendered_any {
274            output.push_str("\n## Timeline\n\n");
275            rendered_any = true;
276        }
277        render_item_heading(
278            output,
279            ordinal,
280            event.actor.as_deref(),
281            event.created_at.as_deref(),
282        );
283        render_timeline_event_body(output, event);
284    }
285}
286
287fn render_default_discussion_item(
288    output: &mut String,
289    ordinal: usize,
290    author: Option<&str>,
291    date: Option<&str>,
292    state: Option<&str>,
293    body: &str,
294    resource: &GithubResource,
295) {
296    let compressed = compress_discussion_body(author, body);
297    if compressed.body.is_empty() {
298        return;
299    }
300    render_item_heading(output, ordinal, author, date);
301    if let Some(state) = state.filter(|state| !state.is_empty()) {
302        output.push_str(&format!("State: {state}\n\n"));
303    }
304    append_body(output, &compressed.body);
305    if compressed.compressed {
306        output.push_str(&format!(
307            "[compressed; full: {}/comments/{ordinal}]\n\n",
308            resource.base_spelling()
309        ));
310    }
311}
312
313fn render_selected_discussion(
314    document: &GithubDocument,
315    selector: &GithubCommentSelector,
316) -> Result<String, GithubReadError> {
317    let items = discussion_items(document);
318    let resolved = selector.resolve(items.len()).map_err(|ordinal| {
319        let valid_range = if items.is_empty() {
320            "empty".to_string()
321        } else {
322            format!("1-{}", items.len())
323        };
324        GithubReadError::InvalidCommentSelector(format!(
325            "discussion ordinal {ordinal} is out of range; valid range is {valid_range}"
326        ))
327    })?;
328
329    let mut output = String::new();
330    for (index, item) in items.into_iter().enumerate() {
331        let ordinal = index + 1;
332        if !resolved.contains(ordinal) {
333            continue;
334        }
335        render_item_heading(&mut output, ordinal, item.author(), item.date());
336        if let Some(event) = item.event() {
337            render_timeline_event_body(&mut output, event);
338            continue;
339        }
340        if let Some(state) = item.state().filter(|state| !state.is_empty()) {
341            output.push_str(&format!("State: {state}\n\n"));
342        }
343        append_body(
344            &mut output,
345            &structural_strip(item.body().unwrap_or_default()),
346        );
347    }
348    Ok(output)
349}
350
351#[derive(Clone, Copy)]
352enum DiscussionItemKind<'a> {
353    Comment(&'a GithubComment),
354    Review(&'a GithubReview),
355    ReviewComment(&'a GithubComment),
356    Event(&'a GithubTimelineEvent),
357}
358
359#[derive(Clone, Copy)]
360struct DiscussionItem<'a> {
361    kind: DiscussionItemKind<'a>,
362}
363
364impl<'a> DiscussionItem<'a> {
365    fn author(self) -> Option<&'a str> {
366        match self.kind {
367            DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
368                comment.author.as_deref()
369            }
370            DiscussionItemKind::Review(review) => review.author.as_deref(),
371            DiscussionItemKind::Event(event) => event.actor.as_deref(),
372        }
373    }
374
375    fn date(self) -> Option<&'a str> {
376        match self.kind {
377            DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
378                comment.created_at.as_deref()
379            }
380            DiscussionItemKind::Review(review) => review.submitted_at.as_deref(),
381            DiscussionItemKind::Event(event) => event.created_at.as_deref(),
382        }
383    }
384
385    fn state(self) -> Option<&'a str> {
386        match self.kind {
387            DiscussionItemKind::Review(review) => review.state.as_deref(),
388            _ => None,
389        }
390    }
391
392    fn body(self) -> Option<&'a str> {
393        match self.kind {
394            DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
395                Some(&comment.body)
396            }
397            DiscussionItemKind::Review(review) => Some(&review.body),
398            DiscussionItemKind::Event(_) => None,
399        }
400    }
401
402    fn event(self) -> Option<&'a GithubTimelineEvent> {
403        match self.kind {
404            DiscussionItemKind::Event(event) => Some(event),
405            _ => None,
406        }
407    }
408}
409
410fn discussion_items(document: &GithubDocument) -> Vec<DiscussionItem<'_>> {
411    let mut items = Vec::new();
412    for comment in newest_comments(&document.comments) {
413        if comment_is_visible(comment) {
414            items.push(DiscussionItem {
415                kind: DiscussionItemKind::Comment(comment),
416            });
417        }
418    }
419    if document.kind == GithubDocumentKind::PullRequest {
420        for review in &document.reviews {
421            if review_is_visible(review) {
422                items.push(DiscussionItem {
423                    kind: DiscussionItemKind::Review(review),
424                });
425            }
426        }
427        for section in &document.review_comment_sections {
428            for comment in newest_comments(&section.comments) {
429                if comment_is_visible(comment) {
430                    items.push(DiscussionItem {
431                        kind: DiscussionItemKind::ReviewComment(comment),
432                    });
433                }
434            }
435        }
436    }
437    if document.timeline.is_empty() {
438        return items;
439    }
440    for event in &document.timeline {
441        items.push(DiscussionItem {
442            kind: DiscussionItemKind::Event(event),
443        });
444    }
445    items.sort_by(|left, right| left.date().unwrap_or("").cmp(right.date().unwrap_or("")));
446    items
447}
448
449/// The kind of discussion item addressed by a read-visible ordinal.
450#[derive(Clone, Copy, Debug, Eq, PartialEq)]
451pub enum GithubDiscussionTarget<'a> {
452    Comment(&'a GithubComment),
453    ReviewThreadComment,
454    Other,
455}
456
457/// Resolve an ordinal using the same unified ordering rendered by read/outline/zoom.
458pub fn discussion_target_at_ordinal(
459    document: &GithubDocument,
460    ordinal: usize,
461) -> Option<GithubDiscussionTarget<'_>> {
462    let discussion = discussion_items(document);
463    let item = discussion.get(ordinal.checked_sub(1)?)?;
464    Some(match item.kind {
465        DiscussionItemKind::Comment(comment) => GithubDiscussionTarget::Comment(comment),
466        DiscussionItemKind::ReviewComment(_) => GithubDiscussionTarget::ReviewThreadComment,
467        DiscussionItemKind::Review(_) | DiscussionItemKind::Event(_) => {
468            GithubDiscussionTarget::Other
469        }
470    })
471}
472
473/// Find a comment's read-visible ordinal after GitHub returns its browser URL.
474pub fn discussion_ordinal_for_comment_url(
475    document: &GithubDocument,
476    comment_url: &str,
477) -> Option<usize> {
478    let discussion = discussion_items(document);
479    let comment = document
480        .comments
481        .iter()
482        .find(|comment| comment.url.as_deref() == Some(comment_url))?;
483    discussion_ordinal_for_comment(&discussion, comment, false)
484}
485
486fn discussion_ordinal_for_comment(
487    discussion: &[DiscussionItem<'_>],
488    comment: &GithubComment,
489    review_comment: bool,
490) -> Option<usize> {
491    discussion
492        .iter()
493        .position(|item| {
494            matches!(
495                item.kind,
496                DiscussionItemKind::ReviewComment(candidate)
497                    if review_comment && std::ptr::eq(candidate, comment)
498            ) || matches!(
499                item.kind,
500                DiscussionItemKind::Comment(candidate)
501                    if !review_comment && std::ptr::eq(candidate, comment)
502            )
503        })
504        .map(|index| index + 1)
505}
506
507fn discussion_ordinal_for_review(
508    discussion: &[DiscussionItem<'_>],
509    review: &GithubReview,
510) -> Option<usize> {
511    discussion
512        .iter()
513        .position(|item| matches!(item.kind, DiscussionItemKind::Review(candidate) if std::ptr::eq(candidate, review)))
514        .map(|index| index + 1)
515}
516
517/// Render the concise GitHub discussion index used by `aft_outline`.
518pub fn render_outline_for_resource(document: &GithubDocument, resource: &GithubResource) -> String {
519    let items = discussion_items(document);
520    let mut lines = vec![format!("#{} {}", document.number, document.title)];
521    let state = if document
522        .timeline
523        .iter()
524        .any(|event| event.event.eq_ignore_ascii_case("merged"))
525    {
526        "merged".to_string()
527    } else {
528        document.state.to_ascii_lowercase()
529    };
530    let author = document
531        .author
532        .as_deref()
533        .map(format_author)
534        .unwrap_or_else(|| "@unknown".to_string());
535    let created = document.created_at.as_deref().unwrap_or("unknown");
536    let updated = document.updated_at.as_deref().unwrap_or("unknown");
537    let labels = document.labels.join(",");
538    let mut metadata = format!(
539        "state={state} author={author} created={created} updated={updated} labels=[{labels}]"
540    );
541    if document.kind == GithubDocumentKind::PullRequest {
542        let base = document.base_ref_name.as_deref().unwrap_or("?");
543        let head = document.head_ref_name.as_deref().unwrap_or("?");
544        let additions = document
545            .files
546            .iter()
547            .map(|file| file.additions.unwrap_or(0))
548            .sum::<u64>();
549        let deletions = document
550            .files
551            .iter()
552            .map(|file| file.deletions.unwrap_or(0))
553            .sum::<u64>();
554        let decision = document
555            .review_decision
556            .as_deref()
557            .unwrap_or("unknown")
558            .to_ascii_lowercase();
559        metadata.push_str(&format!(
560            " {base}<-{head} +{additions}/-{deletions} files={} review={decision}",
561            document.files.len()
562        ));
563    }
564    lines.push(metadata);
565
566    const OUTLINE_ITEM_CAP: usize = 200;
567    let omitted = items.len().saturating_sub(OUTLINE_ITEM_CAP);
568    let first_count = if omitted > 0 { 20 } else { items.len() };
569    for (index, item) in items.iter().take(first_count).enumerate() {
570        lines.push(render_outline_item(index + 1, item));
571    }
572    if omitted > 0 {
573        let first_omitted = first_count + 1;
574        let last_omitted = items.len() - 180;
575        lines.push(format!(
576            "… ({omitted} omitted; read {}/comments/{first_omitted}-{last_omitted})",
577            resource.base_spelling()
578        ));
579        for (index, item) in items.iter().enumerate().skip(items.len() - 180) {
580            lines.push(render_outline_item(index + 1, item));
581        }
582    }
583    lines.push(format!(
584        "Zoom items: aft_zoom {} <k>[,k..] · full: read {}",
585        resource.base_spelling(),
586        resource.base_spelling()
587    ));
588    format!("{}\n", lines.join("\n"))
589}
590
591fn render_outline_item(ordinal: usize, item: &DiscussionItem<'_>) -> String {
592    let kind = match item.kind {
593        DiscussionItemKind::Comment(_) => "comment".to_string(),
594        DiscussionItemKind::Review(_) => format!(
595            "review({})",
596            item.state().unwrap_or("unknown").to_ascii_lowercase()
597        ),
598        DiscussionItemKind::ReviewComment(comment) => format!(
599            "review-comment({}:{})",
600            comment.path.as_deref().unwrap_or("unknown"),
601            comment
602                .line
603                .map(|line| line.to_string())
604                .unwrap_or_else(|| "?".to_string())
605        ),
606        DiscussionItemKind::Event(event) => format!("event({})", event.event),
607    };
608    let author = item
609        .author()
610        .map(format_author)
611        .unwrap_or_else(|| "@unknown".to_string());
612    let date = outline_timestamp(item.date().unwrap_or("unknown"));
613    let body = item.body().map(single_line_excerpt).unwrap_or_else(|| {
614        single_line_excerpt(&timeline_event_payload(item.event().expect("event item")))
615    });
616    format!("[{ordinal}] {kind} {author} {date} · {body}")
617}
618
619fn outline_timestamp(timestamp: &str) -> String {
620    timestamp
621        .strip_suffix(":00Z")
622        .map(|value| value.replace('T', " "))
623        .unwrap_or_else(|| timestamp.replace('T', " "))
624}
625
626fn single_line_excerpt(value: &str) -> String {
627    let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
628    const MAX_CHARS: usize = 80;
629    if text.chars().count() <= MAX_CHARS {
630        return text;
631    }
632    let prefix = text
633        .chars()
634        .take(MAX_CHARS.saturating_sub(1))
635        .collect::<String>();
636    format!("{prefix}…")
637}
638
639fn render_timeline_event_body(output: &mut String, event: &GithubTimelineEvent) {
640    output.push_str(&format!("Event: {}\n\n", event.event));
641    append_body(output, &timeline_event_payload(event));
642}
643
644fn timeline_event_payload(event: &GithubTimelineEvent) -> String {
645    let mut payload = Vec::new();
646    if let Some(label) = &event.label {
647        payload.push(format!("Label: {label}"));
648    }
649    if let Some(assignee) = &event.assignee {
650        payload.push(format!("Assignee: {}", format_author(assignee)));
651    }
652    if let Some(milestone) = &event.milestone {
653        payload.push(format!("Milestone: {milestone}"));
654    }
655    if event.rename_from.is_some() || event.rename_to.is_some() {
656        payload.push(format!(
657            "Renamed: {} -> {}",
658            event.rename_from.as_deref().unwrap_or("unknown"),
659            event.rename_to.as_deref().unwrap_or("unknown")
660        ));
661    }
662    if let Some(reviewer) = &event.requested_reviewer {
663        payload.push(format!("Requested reviewer: {}", format_author(reviewer)));
664    }
665    if let Some(commit_id) = &event.commit_id {
666        payload.push(format!("Merge commit: {commit_id}"));
667    }
668    if payload.is_empty() {
669        event.event.clone()
670    } else {
671        payload.join("; ")
672    }
673}
674
675fn review_is_visible(review: &GithubReview) -> bool {
676    discussion_body_is_visible(review.author.as_deref(), &review.body)
677}
678
679fn comment_is_visible(comment: &GithubComment) -> bool {
680    discussion_body_is_visible(comment.author.as_deref(), &comment.body)
681}
682
683fn discussion_body_is_visible(author: Option<&str>, body: &str) -> bool {
684    !compress_discussion_body(author, body).body.is_empty()
685}
686
687fn render_item_heading(
688    output: &mut String,
689    ordinal: usize,
690    author: Option<&str>,
691    date: Option<&str>,
692) {
693    let author = author
694        .map(format_author)
695        .unwrap_or_else(|| "unknown".to_string());
696    let date = date.unwrap_or("unknown date");
697    output.push_str(&format!("### [{ordinal}] {author} · {date}\n\n"));
698}
699
700fn newest_comments(comments: &[GithubComment]) -> Vec<&GithubComment> {
701    let mut comments: Vec<_> = comments.iter().collect();
702    comments.sort_by(|left, right| compare_timestamp(&left.created_at, &right.created_at));
703    let drop_count = comments
704        .len()
705        .saturating_sub(MAX_RENDERED_COMMENTS_PER_SECTION);
706    comments.into_iter().skip(drop_count).collect()
707}
708
709fn compare_timestamp(left: &Option<String>, right: &Option<String>) -> Ordering {
710    left.as_deref()
711        .unwrap_or("")
712        .cmp(right.as_deref().unwrap_or(""))
713}
714
715fn render_reactions(reactions: &[GithubReaction]) -> Option<String> {
716    let rendered: Vec<_> = reactions
717        .iter()
718        .filter(|reaction| reaction.count > 0)
719        .map(|reaction| {
720            format!(
721                "{} x{}",
722                reaction_display(&reaction.content),
723                reaction.count
724            )
725        })
726        .collect();
727    (!rendered.is_empty()).then(|| rendered.join(", "))
728}
729
730fn reaction_display(reaction: &str) -> &str {
731    match reaction {
732        "THUMBS_UP" | "+1" => "+1",
733        "THUMBS_DOWN" | "-1" => "-1",
734        "LAUGH" => "laugh",
735        "HOORAY" => "hooray",
736        "CONFUSED" => "confused",
737        "HEART" => "heart",
738        "ROCKET" => "rocket",
739        "EYES" => "eyes",
740        other => other,
741    }
742}
743
744fn format_author(author: &str) -> String {
745    if author.starts_with('@') {
746        author.to_string()
747    } else {
748        format!("@{author}")
749    }
750}
751
752fn nonempty(value: &Option<String>) -> Option<&str> {
753    value.as_deref().filter(|value| !value.is_empty())
754}
755
756fn append_body(output: &mut String, body: &str) {
757    if body.is_empty() {
758        return;
759    }
760    output.push_str(body.trim_end());
761    output.push_str("\n\n");
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::github_read::model::{GithubDocument, GithubDocumentKind, GithubTimelineEvent};
768    use crate::github_read::GithubResourceKind;
769
770    #[test]
771    fn renderer_keeps_full_human_body_caps_comments_and_omits_comment_reactions() {
772        let mut document = GithubDocument {
773            repository: "owner/repo".to_string(),
774            kind: GithubDocumentKind::Issue,
775            number: 7,
776            title: "Fixture".to_string(),
777            state: "OPEN".to_string(),
778            author: Some("octo".to_string()),
779            body: "body with https://user-images.githubusercontent.com/example.png".to_string(),
780            reactions: vec![GithubReaction {
781                content: "THUMBS_UP".to_string(),
782                count: 2,
783            }],
784            ..GithubDocument::default()
785        };
786        document.comments = (0..51)
787            .map(|number| GithubComment {
788                author: Some("commenter".to_string()),
789                body: format!("comment {number}"),
790                created_at: Some(format!("2026-01-{:02}T00:00:00Z", number.min(28))),
791                minimized: number == 0,
792                ..GithubComment::default()
793            })
794            .collect();
795        document.comments_total_count = Some(51);
796
797        let text = render_document(&document);
798        assert!(text.contains("Repository: owner/repo"));
799        assert!(text.contains("Reactions: +1 x2"));
800        assert!(text.contains("1 earlier comments omitted"));
801        assert!(text.contains("Minimized comments: 1"));
802        assert!(!text.contains("comment 0\n"));
803        assert!(text.contains("### [1] @commenter"));
804        assert!(text.contains("### [50] @commenter"));
805        assert_eq!(text.matches("/comments/<sel>").count(), 1);
806    }
807
808    #[test]
809    fn timeline_events_share_ordinals_between_outline_read_and_selector() {
810        let document = GithubDocument {
811            repository: "cortexkit/aft".to_string(),
812            kind: GithubDocumentKind::PullRequest,
813            number: 999,
814            title: "Timeline fixture".to_string(),
815            state: "OPEN".to_string(),
816            author: Some("author".to_string()),
817            created_at: Some("2026-09-03T05:00:00Z".to_string()),
818            updated_at: Some("2026-09-03T07:00:00Z".to_string()),
819            comments: vec![
820                GithubComment {
821                    author: Some("commenter".to_string()),
822                    body: "First comment".to_string(),
823                    created_at: Some("2026-09-03T05:10:00Z".to_string()),
824                    ..GithubComment::default()
825                },
826                GithubComment {
827                    author: Some("commenter".to_string()),
828                    body: "Last comment".to_string(),
829                    created_at: Some("2026-09-03T06:50:00Z".to_string()),
830                    ..GithubComment::default()
831                },
832            ],
833            timeline: vec![GithubTimelineEvent {
834                actor: Some("aft-alfonso[bot]".to_string()),
835                event: "closed".to_string(),
836                created_at: Some("2026-09-03T06:57:00Z".to_string()),
837                commit_id: Some("0123456789abcdef".to_string()),
838                ..GithubTimelineEvent::default()
839            }],
840            ..GithubDocument::default()
841        };
842        let resource = GithubResource {
843            kind: GithubResourceKind::PullRequest,
844            number: 999,
845            repository: Some("cortexkit/aft".to_string()),
846            comment_selector: None,
847        };
848
849        let outline = render_outline_for_resource(&document, &resource);
850        let rendered = render_document_for_resource(&document, &resource).expect("render read");
851        let selected = render_document_for_resource(
852            &document,
853            &GithubResource {
854                comment_selector: Some(GithubCommentSelector::parse("3").expect("parse selector")),
855                ..resource.clone()
856            },
857        )
858        .expect("render selected event");
859
860        assert!(outline.contains("[3] event(closed) @aft-alfonso[bot] 2026-09-03 06:57"));
861        assert!(rendered.contains("## Timeline\n\n### [3] @aft-alfonso[bot]"));
862        assert!(selected.contains("Event: closed"));
863        assert!(selected.contains("0123456789abcdef"));
864    }
865
866    #[test]
867    fn zoom_parity_via_shared_enumeration() {
868        let document = GithubDocument {
869            repository: "cortexkit/aft".to_string(),
870            kind: GithubDocumentKind::PullRequest,
871            number: 999,
872            title: "Timeline fixture".to_string(),
873            state: "OPEN".to_string(),
874            comments: vec![
875                GithubComment {
876                    author: Some("commenter".to_string()),
877                    body: "First comment".to_string(),
878                    created_at: Some("2026-09-03T05:10:00Z".to_string()),
879                    ..GithubComment::default()
880                },
881                GithubComment {
882                    author: Some("commenter".to_string()),
883                    body: "Last comment".to_string(),
884                    created_at: Some("2026-09-03T06:50:00Z".to_string()),
885                    ..GithubComment::default()
886                },
887            ],
888            timeline: vec![GithubTimelineEvent {
889                actor: Some("aft-alfonso[bot]".to_string()),
890                event: "closed".to_string(),
891                created_at: Some("2026-09-03T06:57:00Z".to_string()),
892                commit_id: Some("0123456789abcdef".to_string()),
893                ..GithubTimelineEvent::default()
894            }],
895            ..GithubDocument::default()
896        };
897        let resource = GithubResource {
898            kind: GithubResourceKind::PullRequest,
899            number: 999,
900            repository: Some("cortexkit/aft".to_string()),
901            comment_selector: None,
902        };
903
904        let outline = render_outline_for_resource(&document, &resource);
905        assert!(outline.contains("[3] event(closed) @aft-alfonso[bot] 2026-09-03 06:57"));
906
907        let positive = render_document_for_resource(
908            &document,
909            &GithubResource {
910                comment_selector: Some(GithubCommentSelector::parse("3").unwrap()),
911                ..resource.clone()
912            },
913        )
914        .unwrap();
915
916        let tail = render_document_for_resource(
917            &document,
918            &GithubResource {
919                comment_selector: Some(GithubCommentSelector::parse("-1").unwrap()),
920                ..resource.clone()
921            },
922        )
923        .unwrap();
924
925        assert_eq!(tail, positive);
926        assert!(tail.contains("Event: closed"));
927    }
928
929    #[test]
930    fn outline_cap_keeps_first_twenty_last_one_hundred_eighty_and_escape_hatch() {
931        let mut document = GithubDocument {
932            repository: "owner/repo".to_string(),
933            kind: GithubDocumentKind::Issue,
934            number: 7,
935            title: "Cap fixture".to_string(),
936            state: "OPEN".to_string(),
937            ..GithubDocument::default()
938        };
939        document.timeline = (1..=250)
940            .map(|number| GithubTimelineEvent {
941                actor: Some("maintainer".to_string()),
942                event: "labeled".to_string(),
943                created_at: Some(format!("2026-01-01T00:{number:02}:00Z")),
944                label: Some(format!("label {number}")),
945                ..GithubTimelineEvent::default()
946            })
947            .collect();
948        let resource = GithubResource {
949            kind: GithubResourceKind::Issue,
950            number: 7,
951            repository: Some("owner/repo".to_string()),
952            comment_selector: None,
953        };
954
955        let outline = render_outline_for_resource(&document, &resource);
956        assert!(outline.contains("[20] event(labeled)"));
957        assert!(outline.contains("… (50 omitted; read issue://owner/repo/7/comments/21-70)"));
958        assert!(outline.contains("[71] event(labeled)"));
959        assert!(outline.contains("[250] event(labeled)"));
960        assert!(!outline.contains("[21] event(labeled)"));
961    }
962}