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
26fn display_tag(result: &TagResult) {
28 let tag = &result.tag_info;
29
30 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 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 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 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 if let Some(content) = &result.changes {
79 for line in content.lines() {
80 println!("{line}");
81 }
82 }
83 }
84 }
85
86 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 println!(
112 "{}",
113 "No release notes, changelog entry, or previous tag found."
114 .dark_grey()
115 .italic()
116 );
117 }
118}
119
120fn display_enriched(info: EnrichedInfo, filter: &ReleaseFilter) {
123 match &info.entry_point {
124 EntryPoint::IssueNumber(_) => {
125 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); 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 display_identification(&info.entry_point);
153 println!();
154
155 if let Some(pr) = &info.pr {
156 display_pr_section(pr, false); 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 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
200fn 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
242fn 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 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 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
285fn 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 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 print_message_with_essay_joke(&pr.title, pr.body.as_deref(), pr.title.lines().count());
306
307 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
317fn 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 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 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
346fn display_missing_info(info: &EnrichedInfo) {
348 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 if issue.timeline_may_be_incomplete {
365 println!(
366 "{}",
367 " (Timeline data may be incomplete - we might be missing cross-project refs. Check your token access!)"
368 .yellow()
369 .italic()
370 );
371 }
372 println!();
373 }
374
375 if let Some(issue) = info.issue.as_ref()
377 && info.pr.is_some()
378 && issue.timeline_may_be_incomplete
379 {
380 println!(
381 "{}",
382 "⚠️ Timeline data may be incomplete - we might be missing cross-project refs. Check your token access!"
383 .yellow()
384 .italic()
385 );
386 println!();
387 }
388
389 if let Some(pr_info) = info.pr.as_ref()
391 && info.commit.is_none()
392 {
393 if pr_info.merged {
394 println!(
395 "{}",
396 "⏳ PR merged, but alas, the commit is out of reach!"
397 .yellow()
398 .italic()
399 );
400 } else {
401 println!(
402 "{}",
403 "⏳ This PR hasn't been merged yet, too scared to commit!"
404 .yellow()
405 .italic()
406 );
407 }
408 println!();
409 }
410}
411
412fn print_link(url: &str) {
416 println!(" {} {}", "🔗".blue(), url.blue().underlined());
417}
418
419fn print_author_subsection(
421 header: &str,
422 name: &str,
423 email_or_username: Option<&str>,
424 profile_url: Option<&str>,
425) {
426 println!(" {} {}", "👤".yellow(), header.dark_grey());
427
428 if let Some(email_or_username) = email_or_username {
429 println!(" {} ({})", name.cyan(), email_or_username.dark_grey());
430 } else {
431 println!(" {}", name.cyan());
432 }
433
434 if let Some(url) = profile_url {
435 println!(" {} {}", "🔗".blue(), url.blue().underlined());
436 }
437}
438
439fn print_message_with_essay_joke(first_line: &str, full_text: Option<&str>, line_count: usize) {
441 println!(" {} {}", "📝".yellow(), first_line.white().bold());
442
443 if let Some(text) = full_text {
445 let char_count = text.len();
446
447 if char_count > 100 || line_count > 1 {
449 let extra_lines = line_count.saturating_sub(1);
450 let message = if extra_lines > 0 {
451 format!(
452 "Someone likes to write essays... {} more line{}",
453 extra_lines,
454 if extra_lines == 1 { "" } else { "s" }
455 )
456 } else {
457 format!("Someone likes to write essays... {char_count} characters")
458 };
459
460 println!(" {} {}", "📚".yellow(), message.dark_grey().italic());
461 }
462 }
463}
464
465fn display_file(file_result: FileResult, filter: &ReleaseFilter) {
467 let info = file_result.file_info;
468
469 println!("{} {}", "📄 Found file:".green().bold(), info.path.cyan());
470 println!();
471
472 display_commit_section(
474 &info.last_commit,
475 None, );
477
478 let last_author_name = &info.last_commit.author_name;
480 let repeat_count = info
481 .previous_authors
482 .iter()
483 .filter(|(_, name, _)| name == last_author_name)
484 .count();
485
486 if repeat_count > 0 {
488 let joke = match repeat_count {
489 1 => format!(
490 " 💀 {} can't stop touching this file... {} more time before this!",
491 last_author_name.as_str().cyan(),
492 repeat_count
493 ),
494 2 => format!(
495 " 💀 {} really loves this file... {} more times before this!",
496 last_author_name.as_str().cyan(),
497 repeat_count
498 ),
499 3 => format!(
500 " 💀 {} is obsessed... {} more times before this!",
501 last_author_name.as_str().cyan(),
502 repeat_count
503 ),
504 _ => format!(
505 " 💀 {} REALLY needs to leave this alone... {} more times before this!",
506 last_author_name.as_str().cyan(),
507 repeat_count
508 ),
509 };
510 println!("{}", joke.dark_grey().italic());
511 }
512
513 println!();
514
515 if !info.previous_authors.is_empty() {
517 let mut seen_authors = HashSet::new();
519 seen_authors.insert(last_author_name.clone()); let unique_authors: Vec<_> = info
522 .previous_authors
523 .iter()
524 .enumerate()
525 .filter(|(_, (_, name, _))| seen_authors.insert(name.clone()))
526 .collect();
527
528 if !unique_authors.is_empty() {
529 let count = unique_authors.len();
530 let header = if count == 1 {
531 "👻 One ghost from the past:"
532 } else {
533 "👻 The usual suspects (who else touched this):"
534 };
535 println!("{}", header.yellow().bold());
536
537 for (original_idx, (hash, name, _email)) in unique_authors {
538 print!(" → {} • {}", hash.as_str().cyan(), name.as_str().cyan());
539
540 if let Some(Some(url)) = file_result.author_urls.get(original_idx) {
541 print!(" {} {}", "🔗".blue(), url.as_str().blue().underlined());
542 }
543
544 println!();
545 }
546
547 println!();
548 }
549 }
550
551 display_release_info(file_result.release, filter);
553}
554
555fn display_release_info(release: Option<TagInfo>, filter: &ReleaseFilter) {
556 if let Some(tag_name) = filter.specific_tag() {
558 println!("{}", "📦 Release check:".magenta().bold());
559 if let Some(tag) = release {
560 println!(
561 " {} {}",
562 "✅".green(),
563 format!(
564 "Yep, it's in there! {} has got your commit covered.",
565 tag.name
566 )
567 .green()
568 .bold()
569 );
570 if let Some(url) = &tag.tag_url {
572 print_link(url);
573 }
574 } else {
575 println!(
576 " {} {}",
577 "❌".red(),
578 format!("Nope! That commit missed the {tag_name} party.")
579 .yellow()
580 .italic()
581 );
582 }
583 return;
584 }
585
586 println!("{}", "📦 First shipped in:".magenta().bold());
588
589 match release {
590 Some(tag) => {
591 if tag.is_release {
593 if let Some(release_name) = &tag.release_name {
594 println!(
595 " {} {} {}",
596 "🎉".yellow(),
597 release_name.as_str().cyan().bold(),
598 format!("({})", tag.name).as_str().dark_grey()
599 );
600 } else {
601 println!(" {} {}", "🎉".yellow(), tag.name.as_str().cyan().bold());
602 }
603
604 let published_or_created = tag.published_at.unwrap_or(tag.created_at);
606
607 let date_part = published_or_created.format("%Y-%m-%d").to_string();
608 println!(" {} {}", "📅".dark_grey(), date_part.dark_grey());
609 } else {
610 println!(" {} {}", "🏷️ ".yellow(), tag.name.as_str().cyan().bold());
612 }
613
614 if let Some(url) = &tag.tag_url {
616 print_link(url);
617 }
618 }
619 None => {
620 println!(
621 " {}",
622 "🔥 Not shipped yet, still cooking in main!"
623 .yellow()
624 .italic()
625 );
626 }
627 }
628}
629
630fn display_unsupported_host(remote: &RemoteInfo) {
635 match remote.host {
636 Some(RemoteHost::GitLab) => {
637 eprintln!(
638 "{}",
639 "🦊 GitLab spotted! Living that self-hosted life, I see..."
640 .yellow()
641 .italic()
642 );
643 }
644 Some(RemoteHost::Bitbucket) => {
645 eprintln!(
646 "{}",
647 "🪣 Bitbucket, eh? Taking the scenic route!"
648 .yellow()
649 .italic()
650 );
651 }
652 Some(RemoteHost::GitHub) => {
653 return;
655 }
656 None => {
657 eprintln!(
658 "{}",
659 "🌐 A custom git remote? Look at you being all independent!"
660 .yellow()
661 .italic()
662 );
663 }
664 }
665
666 eprintln!(
667 "{}",
668 " (I can only do GitHub API stuff, but let me show you local git info...)"
669 .yellow()
670 .italic()
671 );
672 eprintln!();
673}
674
675fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
676 let host_names: Vec<&str> = hosts
677 .iter()
678 .map(|h| match h {
679 RemoteHost::GitHub => "GitHub",
680 RemoteHost::GitLab => "GitLab",
681 RemoteHost::Bitbucket => "Bitbucket",
682 })
683 .collect();
684
685 eprintln!(
686 "{}",
687 format!(
688 "🤯 Whoa, {} remotes pointing to {}? I'm getting dizzy!",
689 count,
690 host_names.join(", ")
691 )
692 .yellow()
693 .italic()
694 );
695 eprintln!(
696 "{}",
697 " (You've got quite the multi-cloud setup going on here...)"
698 .yellow()
699 .italic()
700 );
701 eprintln!(
702 "{}",
703 " (I can only do GitHub API stuff, but let me show you local git info...)"
704 .yellow()
705 .italic()
706 );
707 eprintln!();
708}
709
710#[allow(clippy::too_many_lines)]
713pub fn print_notice(notice: Notice) {
714 match notice {
715 Notice::NoRemotes => {
717 eprintln!(
718 "{}",
719 "🤐 No remotes configured - what are you hiding?"
720 .yellow()
721 .italic()
722 );
723 eprintln!(
724 "{}",
725 " (Or maybe... go do some OSS? 👀)".yellow().italic()
726 );
727 eprintln!();
728 }
729 Notice::UnsupportedHost { ref best_remote } => {
730 display_unsupported_host(best_remote);
731 }
732 Notice::MixedRemotes { ref hosts, count } => {
733 display_mixed_remotes(hosts, count);
734 }
735 Notice::UnreachableGitHub { ref remote } => {
736 eprintln!(
737 "{}",
738 "🔑 Found a GitHub remote, but can't talk to the API..."
739 .yellow()
740 .italic()
741 );
742 eprintln!(
743 "{}",
744 format!(
745 " Remote '{}' points to GitHub, but no luck connecting.",
746 remote.name
747 )
748 .yellow()
749 .italic()
750 );
751 eprintln!(
752 "{}",
753 " (Missing token? Network hiccup? I'll work with what I've got!)"
754 .yellow()
755 .italic()
756 );
757 eprintln!();
758 }
759 Notice::ApiOnly => {
760 eprintln!(
761 "{}",
762 "📡 Using GitHub API only (local git unavailable)"
763 .yellow()
764 .italic()
765 );
766 eprintln!(
767 "{}",
768 " (Some operations may be slower or limited)"
769 .yellow()
770 .italic()
771 );
772 eprintln!();
773 }
774
775 Notice::CloningRepo { url } => {
777 eprintln!("🔄 Cloning remote repository {url}...");
778 }
779 Notice::CloneSucceeded { used_filter } => {
780 if used_filter {
781 eprintln!("✅ Repository cloned successfully (using filter)");
782 } else {
783 eprintln!("✅ Repository cloned successfully (using bare clone)");
784 }
785 }
786 Notice::CloneFallbackToBare { error } => {
787 eprintln!("⚠️ Filter clone failed ({error}), falling back to bare clone...");
788 }
789 Notice::CacheUpdateFailed { error } => {
790 eprintln!("⚠️ Failed to update cached repo: {error}");
791 }
792 Notice::ShallowRepoDetected => {
793 eprintln!(
794 "⚠️ Shallow repository detected: using API for commit lookup (use --fetch to override)"
795 );
796 }
797 Notice::UpdatingCache => {
798 eprintln!("🔄 Updating cached repository...");
799 }
800 Notice::CacheUpdated => {
801 eprintln!("✅ Repository updated");
802 }
803 Notice::CrossProjectFallbackToApi { owner, repo, error } => {
804 eprintln!(
805 "⚠️ Cannot access git for {owner}/{repo}: {error}. Using API only for cross-project refs."
806 );
807 }
808 Notice::GhRateLimitHit { authenticated } => {
809 if authenticated {
810 eprintln!(
811 "{}",
812 "🐌 Whoa there, speedster! GitHub says slow down..."
813 .yellow()
814 .italic()
815 );
816 eprintln!(
817 "{}",
818 " (Even with a token, there are limits. Take a breather!)"
819 .yellow()
820 .italic()
821 );
822 } else {
823 eprintln!(
824 "{}",
825 "🐌 GitHub's giving us the silent treatment (60 req/hr for strangers)..."
826 .yellow()
827 .italic()
828 );
829 eprintln!(
830 "{}",
831 " (Set GITHUB_TOKEN and they'll be way more chatty - 5000 req/hr!)"
832 .yellow()
833 .italic()
834 );
835 }
836 }
837 Notice::GhAnonymousFallbackFailed { error } => {
838 eprintln!(
839 "{}",
840 "🔑 Tried anonymous fallback, but GitHub wasn't having it..."
841 .yellow()
842 .italic()
843 );
844 eprintln!("{}", format!(" ({error})").yellow().italic());
845 eprintln!(
846 "{}",
847 " (Set GITHUB_TOKEN for more reliable access!)"
848 .yellow()
849 .italic()
850 );
851 }
852 Notice::CrossProjectPrFetchFailed {
853 owner,
854 repo,
855 pr_number,
856 error,
857 } => {
858 eprintln!(
859 "{}",
860 format!(
861 "🔍 Spotted PR #{pr_number} in {owner}/{repo}, but it's playing hard to get..."
862 )
863 .yellow()
864 .italic()
865 );
866 eprintln!("{}", format!(" ({error})").yellow().italic());
867 eprintln!(
868 "{}",
869 " (Cross-project sleuthing needs a GITHUB_TOKEN with access!)"
870 .yellow()
871 .italic()
872 );
873 }
874 }
875}