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