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 println!();
365 }
366
367 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
390fn print_link(url: &str) {
394 println!(" {} {}", "🔗".blue(), url.blue().underlined());
395}
396
397fn 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
417fn print_message_with_essay_joke(first_line: &str, full_text: Option<&str>, line_count: usize) {
419 println!(" {} {}", "📝".yellow(), first_line.white().bold());
420
421 if let Some(text) = full_text {
423 let char_count = text.len();
424
425 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
443fn 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_commit_section(
452 &info.last_commit,
453 None, );
455
456 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 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 if !info.previous_authors.is_empty() {
495 let mut seen_authors = HashSet::new();
497 seen_authors.insert(last_author_name.clone()); 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 display_release_info(file_result.release, filter);
531}
532
533fn display_release_info(release: Option<TagInfo>, filter: &ReleaseFilter) {
534 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 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 println!("{}", "📦 First shipped in:".magenta().bold());
566
567 match release {
568 Some(tag) => {
569 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 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 println!(" {} {}", "🏷️ ".yellow(), tag.name.as_str().cyan().bold());
590 }
591
592 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
608fn 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 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#[allow(clippy::too_many_lines)]
691pub fn print_notice(notice: Notice) {
692 match notice {
693 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 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}