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
25fn 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
50fn display_enriched(info: EnrichedInfo) {
53 match &info.entry_point {
54 EntryPoint::IssueNumber(_) => {
55 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); 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 display_identification(&info.entry_point);
83 println!();
84
85 if let Some(pr) = &info.pr {
86 display_pr_section(pr, false); 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 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
130fn 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
172fn 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 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 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
215fn 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 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 print_message_with_essay_joke(&pr.title, pr.body.as_deref(), pr.title.lines().count());
236
237 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
247fn 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 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 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
276fn display_missing_info(info: &EnrichedInfo) {
278 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 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
320fn print_link(url: &str) {
324 println!(" {} {}", "🔗".blue(), url.blue().underlined());
325}
326
327fn 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
347fn print_message_with_essay_joke(first_line: &str, full_text: Option<&str>, line_count: usize) {
349 println!(" {} {}", "📝".yellow(), first_line.white().bold());
350
351 if let Some(text) = full_text {
353 let char_count = text.len();
354
355 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
373fn 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_commit_section(
382 &info.last_commit,
383 None, );
385
386 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 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 if !info.previous_authors.is_empty() {
425 let mut seen_authors = HashSet::new();
427 seen_authors.insert(last_author_name.clone()); 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 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 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 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 if let Some(url) = &tag.release_url {
489 print_link(url);
490 }
491 } else {
492 println!(" {} {}", "🏷️ ".yellow(), tag.name.as_str().cyan().bold());
494
495 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
515fn 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 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
595pub fn print_notice(notice: Notice) {
598 match notice {
599 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 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}