wtg_cli/
output.rs

1use std::collections::HashSet;
2
3use crossterm::style::Stylize;
4use octocrab::models::IssueState;
5
6use crate::error::WtgResult;
7use crate::git::{CommitInfo, TagInfo};
8use crate::github::PullRequestInfo;
9use crate::notice::Notice;
10use crate::release_filter::ReleaseFilter;
11use crate::remote::{RemoteHost, RemoteInfo};
12use crate::resolution::{
13    ChangesSource, EnrichedInfo, EntryPoint, FileResult, IdentifiedThing, IssueInfo, TagResult,
14};
15
16pub fn display(thing: IdentifiedThing, filter: &ReleaseFilter) -> WtgResult<()> {
17    match thing {
18        IdentifiedThing::Enriched(info) => display_enriched(*info, filter),
19        IdentifiedThing::File(file_result) => display_file(*file_result, filter),
20        IdentifiedThing::Tag(tag_result) => display_tag(&tag_result),
21    }
22
23    Ok(())
24}
25
26/// Display tag information with changes from best available source
27fn display_tag(result: &TagResult) {
28    let tag = &result.tag_info;
29
30    // Header
31    println!("{} {}", "🏷️  Tag:".green().bold(), tag.name.as_str().cyan());
32    println!(
33        "{} {}",
34        "📅 Created:".yellow(),
35        tag.created_at.format("%Y-%m-%d").to_string().dark_grey()
36    );
37
38    // URL - prefer release URL if available
39    if let Some(url) = tag.release_url.as_ref().or(result.github_url.as_ref()) {
40        println!(
41            "{} {}",
42            "🔗 Release:".blue(),
43            url.as_str().blue().underlined()
44        );
45    }
46
47    println!();
48
49    // Changes section
50    if let Some(source) = &result.changes_source {
51        let source_label = match source {
52            ChangesSource::GitHubRelease => "(from GitHub release)".to_string(),
53            ChangesSource::Changelog => "(from CHANGELOG)".to_string(),
54            ChangesSource::Commits { previous_tag } => {
55                format!("(commits since {previous_tag})")
56            }
57        };
58
59        println!(
60            "{} {}",
61            "Changes".magenta().bold(),
62            source_label.as_str().dark_grey()
63        );
64
65        match source {
66            ChangesSource::Commits { .. } => {
67                // Display commits as bullet list
68                for commit in &result.commits {
69                    println!(
70                        "• {} {}",
71                        commit.short_hash.as_str().cyan(),
72                        commit.message.as_str().white()
73                    );
74                }
75            }
76            _ => {
77                // Display text content
78                if let Some(content) = &result.changes {
79                    for line in content.lines() {
80                        println!("{line}");
81                    }
82                }
83            }
84        }
85
86        // Truncation notice
87        if result.truncated_lines > 0 {
88            if let Some(url) = tag.release_url.as_ref().or(result.github_url.as_ref()) {
89                println!(
90                    "{}",
91                    format!(
92                        "... {} more lines (see full release at {})",
93                        result.truncated_lines, url
94                    )
95                    .as_str()
96                    .dark_grey()
97                    .italic()
98                );
99            } else {
100                println!(
101                    "{}",
102                    format!("... {} more lines", result.truncated_lines)
103                        .as_str()
104                        .dark_grey()
105                        .italic()
106                );
107            }
108        }
109    } else {
110        // No changes available - just show the tag exists
111        println!(
112            "{}",
113            "No release notes, changelog entry, or previous tag found."
114                .dark_grey()
115                .italic()
116        );
117    }
118}
119
120/// Display enriched info - the main display logic
121/// Order depends on what the user searched for
122fn display_enriched(info: EnrichedInfo, filter: &ReleaseFilter) {
123    match &info.entry_point {
124        EntryPoint::IssueNumber(_) => {
125            // User searched for issue - lead with issue
126            display_identification(&info.entry_point);
127            println!();
128
129            if let Some(issue) = &info.issue {
130                display_issue_section(issue);
131                println!();
132            }
133
134            if let Some(pr) = &info.pr {
135                display_pr_section(pr, true); // true = show as "the fix"
136                println!();
137            }
138
139            if let Some(commit_info) = info.commit.as_ref() {
140                display_commit_section(commit_info, info.pr.as_ref());
141                println!();
142            }
143
144            display_missing_info(&info);
145
146            if info.commit.is_some() {
147                display_release_info(info.release, filter);
148            }
149        }
150        EntryPoint::PullRequestNumber(_) => {
151            // User searched for PR - lead with PR
152            display_identification(&info.entry_point);
153            println!();
154
155            if let Some(pr) = &info.pr {
156                display_pr_section(pr, false); // false = not a fix, just a PR
157                println!();
158            }
159
160            if let Some(commit_info) = info.commit.as_ref() {
161                display_commit_section(commit_info, info.pr.as_ref());
162                println!();
163            }
164
165            display_missing_info(&info);
166
167            if info.commit.is_some() {
168                display_release_info(info.release, filter);
169            }
170        }
171        _ => {
172            // User searched for commit or something else - lead with commit
173            display_identification(&info.entry_point);
174            println!();
175
176            if let Some(commit_info) = info.commit.as_ref() {
177                display_commit_section(commit_info, info.pr.as_ref());
178                println!();
179            }
180
181            if let Some(pr) = &info.pr {
182                display_pr_section(pr, false);
183                println!();
184            }
185
186            if let Some(issue) = &info.issue {
187                display_issue_section(issue);
188                println!();
189            }
190
191            display_missing_info(&info);
192
193            if info.commit.is_some() {
194                display_release_info(info.release, filter);
195            }
196        }
197    }
198}
199
200/// Display what the user searched for
201fn display_identification(entry_point: &EntryPoint) {
202    match entry_point {
203        EntryPoint::Commit(hash) => {
204            println!(
205                "{} {}",
206                "🔍 Found commit:".green().bold(),
207                hash.as_str().cyan()
208            );
209        }
210        EntryPoint::PullRequestNumber(num) => {
211            println!(
212                "{} #{}",
213                "🔀 Found PR:".green().bold(),
214                num.to_string().cyan()
215            );
216        }
217        EntryPoint::IssueNumber(num) => {
218            println!(
219                "{} #{}",
220                "🐛 Found issue:".green().bold(),
221                num.to_string().cyan()
222            );
223        }
224        EntryPoint::FilePath { branch, path } => {
225            println!(
226                "{} {}@{}",
227                "📄 Found file:".green().bold(),
228                path.as_str().cyan(),
229                branch.clone().cyan()
230            );
231        }
232        EntryPoint::Tag(tag) => {
233            println!(
234                "{} {}",
235                "🏷️  Found tag:".green().bold(),
236                tag.as_str().cyan()
237            );
238        }
239    }
240}
241
242/// Display commit information (the core section, always present when resolved)
243fn display_commit_section(commit_info: &CommitInfo, pr: Option<&PullRequestInfo>) {
244    let commit_url = commit_info.commit_url.as_deref();
245    let author_url = commit_info.author_url.as_deref();
246
247    println!("{}", "💻 The Commit:".cyan().bold());
248    println!(
249        "   {} {}",
250        "Hash:".yellow(),
251        commit_info.short_hash.as_str().cyan()
252    );
253
254    // Show commit author
255    print_author_subsection(
256        "Who wrote this gem:",
257        &commit_info.author_name,
258        commit_info
259            .author_login
260            .as_deref()
261            .or(commit_info.author_email.as_deref()),
262        author_url,
263    );
264
265    // Show commit message if not a PR
266    if pr.is_none() {
267        print_message_with_essay_joke(&commit_info.message, None, commit_info.message_lines);
268    }
269
270    println!(
271        "   {} {}",
272        "📅".yellow(),
273        commit_info
274            .date
275            .format("%Y-%m-%d %H:%M:%S")
276            .to_string()
277            .dark_grey()
278    );
279
280    if let Some(url) = commit_url {
281        print_link(url);
282    }
283}
284
285/// Display PR information (enrichment layer 1)
286fn display_pr_section(pr: &PullRequestInfo, is_fix: bool) {
287    println!("{}", "🔀 The Pull Request:".magenta().bold());
288    println!(
289        "   {} #{}",
290        "Number:".yellow(),
291        pr.number.to_string().cyan()
292    );
293
294    // PR author - different wording if this is shown as "the fix" for an issue
295    if let Some(author) = &pr.author {
296        let header = if is_fix {
297            "Who's brave:"
298        } else {
299            "Who merged this beauty:"
300        };
301        print_author_subsection(header, author, None, pr.author_url.as_deref());
302    }
303
304    // PR description (overrides commit message)
305    print_message_with_essay_joke(&pr.title, pr.body.as_deref(), pr.title.lines().count());
306
307    // Merge status
308    if let Some(merge_sha) = &pr.merge_commit_sha {
309        println!("   {} {}", "✅ Merged:".green(), merge_sha[..7].cyan());
310    } else {
311        println!("   {}", "❌ Not merged yet".yellow().italic());
312    }
313
314    print_link(&pr.url);
315}
316
317/// Display issue information (enrichment layer 2)
318fn display_issue_section(issue: &IssueInfo) {
319    println!("{}", "🐛 The Issue:".red().bold());
320    println!(
321        "   {} #{}",
322        "Number:".yellow(),
323        issue.number.to_string().cyan()
324    );
325
326    // Issue author
327    if let Some(author) = &issue.author {
328        print_author_subsection(
329            "Who spotted the trouble:",
330            author,
331            None,
332            issue.author_url.as_deref(),
333        );
334    }
335
336    // Issue description
337    print_message_with_essay_joke(
338        &issue.title,
339        issue.body.as_deref(),
340        issue.title.lines().count(),
341    );
342
343    print_link(&issue.url);
344}
345
346/// Display missing information (graceful degradation)
347fn display_missing_info(info: &EnrichedInfo) {
348    // Issue without PR
349    if let Some(issue) = info.issue.as_ref()
350        && info.pr.is_none()
351    {
352        let message = if info.commit.is_none() {
353            if issue.state == IssueState::Closed {
354                "🔍 Issue closed, but the trail's cold. Some stealthy hero dropped a fix and vanished without a PR."
355            } else {
356                "🔍 Couldn't trace this issue, still open. Waiting for a brave soul to pick it up..."
357            }
358        } else if issue.state == IssueState::Closed {
359            "🤷 Issue closed, but no PR found... Some stealthy hero dropped a fix and vanished without a PR."
360        } else {
361            "🤷 No PR found for this issue... still hunting for the fix!"
362        };
363        println!("{}", message.yellow().italic());
364        println!();
365    }
366
367    // PR without commit (either not merged, or merged but from a cross-project ref we do not have access to)
368    if let Some(pr_info) = info.pr.as_ref()
369        && info.commit.is_none()
370    {
371        if pr_info.merged {
372            println!(
373                "{}",
374                "⏳ PR merged, but alas, the commit is out of reach!"
375                    .yellow()
376                    .italic()
377            );
378        } else {
379            println!(
380                "{}",
381                "⏳ This PR hasn't been merged yet, too scared to commit!"
382                    .yellow()
383                    .italic()
384            );
385        }
386        println!();
387    }
388}
389
390// Helper functions for consistent formatting
391
392/// Print a clickable URL with consistent styling
393fn print_link(url: &str) {
394    println!("   {} {}", "🔗".blue(), url.blue().underlined());
395}
396
397/// Print author information as a subsection (indented)
398fn print_author_subsection(
399    header: &str,
400    name: &str,
401    email_or_username: Option<&str>,
402    profile_url: Option<&str>,
403) {
404    println!("   {} {}", "👤".yellow(), header.dark_grey());
405
406    if let Some(email_or_username) = email_or_username {
407        println!("      {} ({})", name.cyan(), email_or_username.dark_grey());
408    } else {
409        println!("      {}", name.cyan());
410    }
411
412    if let Some(url) = profile_url {
413        println!("      {} {}", "🔗".blue(), url.blue().underlined());
414    }
415}
416
417/// Print a message/description with essay joke if it's long
418fn print_message_with_essay_joke(first_line: &str, full_text: Option<&str>, line_count: usize) {
419    println!("   {} {}", "📝".yellow(), first_line.white().bold());
420
421    // Check if we should show the essay joke
422    if let Some(text) = full_text {
423        let char_count = text.len();
424
425        // Show essay joke if >100 chars or multi-line
426        if char_count > 100 || line_count > 1 {
427            let extra_lines = line_count.saturating_sub(1);
428            let message = if extra_lines > 0 {
429                format!(
430                    "Someone likes to write essays... {} more line{}",
431                    extra_lines,
432                    if extra_lines == 1 { "" } else { "s" }
433                )
434            } else {
435                format!("Someone likes to write essays... {char_count} characters")
436            };
437
438            println!("      {} {}", "📚".yellow(), message.dark_grey().italic());
439        }
440    }
441}
442
443/// Display file information (special case)
444fn display_file(file_result: FileResult, filter: &ReleaseFilter) {
445    let info = file_result.file_info;
446
447    println!("{} {}", "📄 Found file:".green().bold(), info.path.cyan());
448    println!();
449
450    // Display the commit section (consistent with PR/issue flow)
451    display_commit_section(
452        &info.last_commit,
453        None, // Files don't have associated PRs
454    );
455
456    // Count how many times the last commit author appears in previous commits
457    let last_author_name = &info.last_commit.author_name;
458    let repeat_count = info
459        .previous_authors
460        .iter()
461        .filter(|(_, name, _)| name == last_author_name)
462        .count();
463
464    // Add snarky comment if they're a repeat offender
465    if repeat_count > 0 {
466        let joke = match repeat_count {
467            1 => format!(
468                "   💀 {} can't stop touching this file... {} more time before this!",
469                last_author_name.as_str().cyan(),
470                repeat_count
471            ),
472            2 => format!(
473                "   💀 {} really loves this file... {} more times before this!",
474                last_author_name.as_str().cyan(),
475                repeat_count
476            ),
477            3 => format!(
478                "   💀 {} is obsessed... {} more times before this!",
479                last_author_name.as_str().cyan(),
480                repeat_count
481            ),
482            _ => format!(
483                "   💀 {} REALLY needs to leave this alone... {} more times before this!",
484                last_author_name.as_str().cyan(),
485                repeat_count
486            ),
487        };
488        println!("{}", joke.dark_grey().italic());
489    }
490
491    println!();
492
493    // Previous authors - snarky hall of shame (deduplicated)
494    if !info.previous_authors.is_empty() {
495        // Deduplicate authors - track who we've seen
496        let mut seen_authors = HashSet::new();
497        seen_authors.insert(last_author_name.clone()); // Skip the last commit author
498
499        let unique_authors: Vec<_> = info
500            .previous_authors
501            .iter()
502            .enumerate()
503            .filter(|(_, (_, name, _))| seen_authors.insert(name.clone()))
504            .collect();
505
506        if !unique_authors.is_empty() {
507            let count = unique_authors.len();
508            let header = if count == 1 {
509                "👻 One ghost from the past:"
510            } else {
511                "👻 The usual suspects (who else touched this):"
512            };
513            println!("{}", header.yellow().bold());
514
515            for (original_idx, (hash, name, _email)) in unique_authors {
516                print!("   → {} • {}", hash.as_str().cyan(), name.as_str().cyan());
517
518                if let Some(Some(url)) = file_result.author_urls.get(original_idx) {
519                    print!(" {} {}", "🔗".blue(), url.as_str().blue().underlined());
520                }
521
522                println!();
523            }
524
525            println!();
526        }
527    }
528
529    // Release info
530    display_release_info(file_result.release, filter);
531}
532
533fn display_release_info(release: Option<TagInfo>, filter: &ReleaseFilter) {
534    // Special messaging when checking a specific release
535    if let Some(tag_name) = filter.specific_tag() {
536        println!("{}", "📦 Release check:".magenta().bold());
537        if let Some(tag) = release {
538            println!(
539                "   {} {}",
540                "✅".green(),
541                format!(
542                    "Yep, it's in there! {} has got your commit covered.",
543                    tag.name
544                )
545                .green()
546                .bold()
547            );
548            // Use pre-computed tag_url (release URL for releases, tree URL for plain tags)
549            if let Some(url) = &tag.tag_url {
550                print_link(url);
551            }
552        } else {
553            println!(
554                "   {} {}",
555                "❌".red(),
556                format!("Nope! That commit missed the {tag_name} party.")
557                    .yellow()
558                    .italic()
559            );
560        }
561        return;
562    }
563
564    // Standard release info display
565    println!("{}", "📦 First shipped in:".magenta().bold());
566
567    match release {
568        Some(tag) => {
569            // Display tag name (or release name if it's a GitHub release)
570            if tag.is_release {
571                if let Some(release_name) = &tag.release_name {
572                    println!(
573                        "   {} {} {}",
574                        "🎉".yellow(),
575                        release_name.as_str().cyan().bold(),
576                        format!("({})", tag.name).as_str().dark_grey()
577                    );
578                } else {
579                    println!("   {} {}", "🎉".yellow(), tag.name.as_str().cyan().bold());
580                }
581
582                // Show published date if available, fallback to tag date
583                let published_or_created = tag.published_at.unwrap_or(tag.created_at);
584
585                let date_part = published_or_created.format("%Y-%m-%d").to_string();
586                println!("   {} {}", "📅".dark_grey(), date_part.dark_grey());
587            } else {
588                // Plain git tag
589                println!("   {} {}", "🏷️ ".yellow(), tag.name.as_str().cyan().bold());
590            }
591
592            // Use pre-computed tag_url (release URL for releases, tree URL for plain tags)
593            if let Some(url) = &tag.tag_url {
594                print_link(url);
595            }
596        }
597        None => {
598            println!(
599                "   {}",
600                "🔥 Not shipped yet, still cooking in main!"
601                    .yellow()
602                    .italic()
603            );
604        }
605    }
606}
607
608// ============================================
609// Notice display
610// ============================================
611
612fn display_unsupported_host(remote: &RemoteInfo) {
613    match remote.host {
614        Some(RemoteHost::GitLab) => {
615            println!(
616                "{}",
617                "🦊 GitLab spotted! Living that self-hosted life, I see..."
618                    .yellow()
619                    .italic()
620            );
621        }
622        Some(RemoteHost::Bitbucket) => {
623            println!(
624                "{}",
625                "🪣 Bitbucket, eh? Taking the scenic route!"
626                    .yellow()
627                    .italic()
628            );
629        }
630        Some(RemoteHost::GitHub) => {
631            // Shouldn't happen, but handle gracefully
632            return;
633        }
634        None => {
635            println!(
636                "{}",
637                "🌐 A custom git remote? Look at you being all independent!"
638                    .yellow()
639                    .italic()
640            );
641        }
642    }
643
644    println!(
645        "{}",
646        "   (I can only do GitHub API stuff, but let me show you local git info...)"
647            .yellow()
648            .italic()
649    );
650    println!();
651}
652
653fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
654    let host_names: Vec<&str> = hosts
655        .iter()
656        .map(|h| match h {
657            RemoteHost::GitHub => "GitHub",
658            RemoteHost::GitLab => "GitLab",
659            RemoteHost::Bitbucket => "Bitbucket",
660        })
661        .collect();
662
663    println!(
664        "{}",
665        format!(
666            "🤯 Whoa, {} remotes pointing to {}? I'm getting dizzy!",
667            count,
668            host_names.join(", ")
669        )
670        .yellow()
671        .italic()
672    );
673    println!(
674        "{}",
675        "   (You've got quite the multi-cloud setup going on here...)"
676            .yellow()
677            .italic()
678    );
679    println!(
680        "{}",
681        "   (I can only do GitHub API stuff, but let me show you local git info...)"
682            .yellow()
683            .italic()
684    );
685    println!();
686}
687
688/// Print a notice to stderr.
689/// All notices (both capability warnings and operational info) go through this function.
690#[allow(clippy::too_many_lines)]
691pub fn print_notice(notice: Notice) {
692    match notice {
693        // --- Backend capability notices ---
694        Notice::NoRemotes => {
695            eprintln!(
696                "{}",
697                "🤐 No remotes configured - what are you hiding?"
698                    .yellow()
699                    .italic()
700            );
701            eprintln!(
702                "{}",
703                "   (Or maybe... go do some OSS? 👀)".yellow().italic()
704            );
705            eprintln!();
706        }
707        Notice::UnsupportedHost { ref best_remote } => {
708            display_unsupported_host(best_remote);
709        }
710        Notice::MixedRemotes { ref hosts, count } => {
711            display_mixed_remotes(hosts, count);
712        }
713        Notice::UnreachableGitHub { ref remote } => {
714            eprintln!(
715                "{}",
716                "🔑 Found a GitHub remote, but can't talk to the API..."
717                    .yellow()
718                    .italic()
719            );
720            eprintln!(
721                "{}",
722                format!(
723                    "   Remote '{}' points to GitHub, but no luck connecting.",
724                    remote.name
725                )
726                .yellow()
727                .italic()
728            );
729            eprintln!(
730                "{}",
731                "   (Missing token? Network hiccup? I'll work with what I've got!)"
732                    .yellow()
733                    .italic()
734            );
735            eprintln!();
736        }
737        Notice::ApiOnly => {
738            eprintln!(
739                "{}",
740                "📡 Using GitHub API only (local git unavailable)"
741                    .yellow()
742                    .italic()
743            );
744            eprintln!(
745                "{}",
746                "   (Some operations may be slower or limited)"
747                    .yellow()
748                    .italic()
749            );
750            eprintln!();
751        }
752
753        // --- Operational notices ---
754        Notice::CloningRepo { url } => {
755            eprintln!("🔄 Cloning remote repository {url}...");
756        }
757        Notice::CloneSucceeded { used_filter } => {
758            if used_filter {
759                eprintln!("✅ Repository cloned successfully (using filter)");
760            } else {
761                eprintln!("✅ Repository cloned successfully (using bare clone)");
762            }
763        }
764        Notice::CloneFallbackToBare { error } => {
765            eprintln!("⚠️  Filter clone failed ({error}), falling back to bare clone...");
766        }
767        Notice::CacheUpdateFailed { error } => {
768            eprintln!("⚠️  Failed to update cached repo: {error}");
769        }
770        Notice::ShallowRepoDetected => {
771            eprintln!(
772                "⚠️  Shallow repository detected: using API for commit lookup (use --fetch to override)"
773            );
774        }
775        Notice::UpdatingCache => {
776            eprintln!("🔄 Updating cached repository...");
777        }
778        Notice::CacheUpdated => {
779            eprintln!("✅ Repository updated");
780        }
781        Notice::CrossProjectFallbackToApi { owner, repo, error } => {
782            eprintln!(
783                "⚠️  Cannot access git for {owner}/{repo}: {error}. Using API only for cross-project refs."
784            );
785        }
786        Notice::GhRateLimitHit { authenticated } => {
787            if authenticated {
788                eprintln!(
789                    "{}",
790                    "🐌 Whoa there, speedster! GitHub says slow down..."
791                        .yellow()
792                        .italic()
793                );
794                eprintln!(
795                    "{}",
796                    "   (Even with a token, there are limits. Take a breather!)"
797                        .yellow()
798                        .italic()
799                );
800            } else {
801                eprintln!(
802                    "{}",
803                    "🐌 GitHub's giving us the silent treatment (60 req/hr for strangers)..."
804                        .yellow()
805                        .italic()
806                );
807                eprintln!(
808                    "{}",
809                    "   (Set GITHUB_TOKEN and they'll be way more chatty - 5000 req/hr!)"
810                        .yellow()
811                        .italic()
812                );
813            }
814        }
815    }
816}