gitfetch_rs/display/
formatter.rs

1use super::graph::ContributionGraph;
2use crate::config::Config;
3use anyhow::Result;
4use serde_json::Value;
5
6#[derive(Debug)]
7enum Layout {
8  Minimal,
9  Compact,
10  Full,
11}
12
13#[derive(Debug, Clone, Default)]
14pub struct VisualOptions {
15  pub graph_only: bool,
16  pub spaced: bool,
17  pub graph_timeline: bool,
18  pub width: Option<usize>,
19  pub height: Option<usize>,
20  pub no_achievements: bool,
21  pub no_languages: bool,
22  pub no_issues: bool,
23  pub no_pr: bool,
24  pub no_account: bool,
25  pub no_grid: bool,
26}
27
28pub struct DisplayFormatter {
29  config: Config,
30  terminal_width: usize,
31  terminal_height: usize,
32  visual_opts: VisualOptions,
33}
34
35impl DisplayFormatter {
36  pub fn new(config: Config, visual_opts: VisualOptions) -> Result<Self> {
37    let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
38
39    Ok(Self {
40      config,
41      terminal_width: cols as usize,
42      terminal_height: rows as usize,
43      visual_opts,
44    })
45  }
46
47  pub fn display(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
48    // Handle --graph-timeline option
49    if self.visual_opts.graph_timeline {
50      let timeline = crate::utils::timeline::get_git_timeline_graph(false)?;
51      println!();
52      println!("{}", timeline);
53      return Ok(());
54    }
55
56    // Handle --graph-only option
57    if self.visual_opts.graph_only {
58      let width_constraint = self.terminal_width.saturating_sub(4);
59      let graph_lines =
60        self.get_contribution_graph_lines_with_width(username, stats, width_constraint)?;
61      for line in graph_lines {
62        println!("{}", line);
63      }
64      println!();
65      return Ok(());
66    }
67
68    let layout = self.determine_layout(username, user_data, stats);
69
70    match layout {
71      Layout::Minimal => self.display_minimal(username, stats)?,
72      Layout::Compact => self.display_compact(username, user_data, stats)?,
73      Layout::Full => self.display_full(username, user_data, stats)?,
74    }
75
76    println!();
77    Ok(())
78  }
79
80  fn determine_layout(&self, username: &str, user_data: &Value, stats: &Value) -> Layout {
81    // Try layouts in order: full -> compact -> minimal
82    // Choose the first one that fits in terminal dimensions
83    let layouts = vec![Layout::Full, Layout::Compact, Layout::Minimal];
84
85    let available_height = self.terminal_height.saturating_sub(2).max(10);
86
87    for layout in layouts {
88      let (width, height) = self.calculate_layout_dimensions(username, user_data, stats, &layout);
89
90      if width <= self.terminal_width && height <= available_height {
91        return layout;
92      }
93    }
94
95    Layout::Minimal
96  }
97
98  fn calculate_layout_dimensions(
99    &self,
100    username: &str,
101    user_data: &Value,
102    stats: &Value,
103    layout: &Layout,
104  ) -> (usize, usize) {
105    match layout {
106      Layout::Minimal => self.calculate_minimal_dimensions(username, stats),
107      Layout::Compact => self.calculate_compact_dimensions(username, user_data, stats),
108      Layout::Full => self.calculate_full_dimensions(username, user_data, stats),
109    }
110  }
111
112  fn calculate_minimal_dimensions(&self, _username: &str, _stats: &Value) -> (usize, usize) {
113    if !self.visual_opts.no_grid {
114      let width = self
115        .visual_opts
116        .width
117        .unwrap_or(self.terminal_width.saturating_sub(4));
118      let height = self.visual_opts.height.unwrap_or(7);
119      (width, height)
120    } else {
121      // Just header
122      (50, 2)
123    }
124  }
125
126  fn calculate_compact_dimensions(
127    &self,
128    _username: &str,
129    user_data: &Value,
130    stats: &Value,
131  ) -> (usize, usize) {
132    let graph_width = self
133      .visual_opts
134      .width
135      .unwrap_or_else(|| (self.terminal_width.saturating_sub(40).max(40) * 3) / 4);
136
137    let graph_height = if !self.visual_opts.no_grid {
138      // Graph height = days to show + 1 for month labels line
139      self.visual_opts.height.unwrap_or(7) + 1
140    } else {
141      // Just header dimensions when no grid
142      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
143      let total_contribs = graph.calculate_total_contributions();
144      let name = user_data["name"].as_str().unwrap_or("unknown");
145      let header_text = format!("{} - {} contributions this year", name, total_contribs);
146      return (self.display_width(&header_text), 1);
147    };
148
149    // Calculate actual right side content
150    let mut right_lines = Vec::new();
151
152    if !self.visual_opts.no_account {
153      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
154      let total_contribs = graph.calculate_total_contributions();
155      let name = user_data["name"].as_str().unwrap_or("unknown");
156      let info_text = format!("{} - {} contributions this year", name, total_contribs);
157      right_lines.push(info_text);
158    }
159
160    if !self.visual_opts.no_achievements {
161      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
162      let (current_streak, max_streak) = graph.calculate_streaks();
163      let total_contribs = graph.calculate_total_contributions();
164
165      if !right_lines.is_empty() {
166        right_lines.push(String::new());
167      }
168
169      right_lines.push("ACHIEVEMENTS".to_string());
170      right_lines.push("────────────".to_string());
171
172      if current_streak > 0 {
173        right_lines.push(format!("🔥 Current Streak  {} days", current_streak));
174      }
175      if max_streak > 0 {
176        right_lines.push(format!("⭐ Best Streak     {} days", max_streak));
177      }
178      if total_contribs >= 100 {
179        right_lines.push("🏆 Contributions   100+".to_string());
180      }
181    }
182
183    let max_lines = graph_height.max(right_lines.len());
184    let right_width = right_lines
185      .iter()
186      .map(|line| self.display_width(line))
187      .max()
188      .unwrap_or(0);
189
190    // Add 2: one for display_compact's println!(), one for display()'s println!()
191    (graph_width + 2 + right_width, max_lines + 2)
192  }
193
194  fn calculate_full_dimensions(
195    &self,
196    _username: &str,
197    user_data: &Value,
198    stats: &Value,
199  ) -> (usize, usize) {
200    let graph_width = self
201      .visual_opts
202      .width
203      .unwrap_or_else(|| ((self.terminal_width.saturating_sub(10).max(50) * 3) / 4).max(50));
204
205    // Calculate actual left side height by simulating rendering
206    let left_height = if !self.visual_opts.no_grid {
207      let base_height = self.visual_opts.height.unwrap_or(7);
208      let mut total = base_height + 1; // +1 for month line
209
210      // Actually calculate PR/Issues line counts
211      if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
212        total += 1; // spacing line
213
214        let pr_lines = if !self.visual_opts.no_pr {
215          self.format_pull_requests(stats).len()
216        } else {
217          0
218        };
219        let issue_lines = if !self.visual_opts.no_issues {
220          self.format_issues(stats).len()
221        } else {
222          0
223        };
224
225        // Sections displayed side-by-side, use max
226        total += pr_lines.max(issue_lines);
227      }
228      total
229    } else {
230      0
231    };
232
233    // Calculate right side content with ACTUAL rendered lines
234    let mut right_lines = Vec::new();
235
236    if !self.visual_opts.no_account {
237      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
238      let total_contribs = graph.calculate_total_contributions();
239
240      // Use actual format_user_info to get real line count
241      let user_info_lines = self.format_user_info("", user_data, stats, total_contribs);
242      right_lines.extend(user_info_lines);
243    }
244
245    // Only show languages if terminal width >= 120
246    if !self.visual_opts.no_languages && self.terminal_width >= 120 {
247      let language_lines = self.format_languages(stats);
248      if !language_lines.is_empty() {
249        if !right_lines.is_empty() {
250          right_lines.push(String::new());
251        }
252        right_lines.extend(language_lines);
253      }
254    }
255
256    if !self.visual_opts.no_achievements {
257      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
258      let achievement_lines = self.format_achievements(&graph);
259      if !achievement_lines.is_empty() {
260        if !right_lines.is_empty() {
261          right_lines.push(String::new());
262        }
263        right_lines.extend(achievement_lines);
264      }
265    }
266
267    let max_height = left_height.max(right_lines.len());
268
269    // Calculate ACTUAL widths by rendering and measuring
270    // We need to actually render the left side to get accurate widths
271    let left_width = if !self.visual_opts.no_grid {
272      // Render actual graph lines with the calculated width constraint
273      let graph_lines = self
274        .get_contribution_graph_lines_with_width(_username, stats, graph_width)
275        .unwrap_or_default();
276
277      // If PR/Issues are shown, we need to include those too
278      let mut all_left_lines = graph_lines;
279
280      if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
281        let pr_lines = if !self.visual_opts.no_pr {
282          self.format_pull_requests(stats)
283        } else {
284          vec![]
285        };
286        let issue_lines = if !self.visual_opts.no_issues {
287          self.format_issues(stats)
288        } else {
289          vec![]
290        };
291
292        if !pr_lines.is_empty() || !issue_lines.is_empty() {
293          all_left_lines.push(String::new());
294
295          let pr_width = pr_lines
296            .iter()
297            .map(|l| self.display_width(l))
298            .max()
299            .unwrap_or(0);
300          let max_section_lines = pr_lines.len().max(issue_lines.len());
301
302          for i in 0..max_section_lines {
303            let pr_part = if i < pr_lines.len() { &pr_lines[i] } else { "" };
304            let issue_part = if i < issue_lines.len() {
305              &issue_lines[i]
306            } else {
307              ""
308            };
309            let pr_part_width = self.display_width(pr_part);
310            let padding_len = pr_width.saturating_sub(pr_part_width) + 3;
311
312            // Simulate the actual line format
313            let line = format!("    {}{}{}", pr_part, " ".repeat(padding_len), issue_part);
314            all_left_lines.push(line);
315          }
316        }
317      }
318
319      // Get max width of all left lines
320      all_left_lines
321        .iter()
322        .map(|l| self.display_width(l))
323        .max()
324        .unwrap_or(0)
325    } else {
326      0
327    };
328
329    let right_width = right_lines
330      .iter()
331      .map(|line| self.display_width(line))
332      .max()
333      .unwrap_or(0);
334
335    // Add 2: one for display_full's println!(), one for display()'s println!()
336    (left_width + 2 + right_width, max_height + 2)
337  }
338  fn display_minimal(&self, username: &str, stats: &Value) -> Result<()> {
339    println!();
340
341    if self.visual_opts.no_grid {
342      let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
343      let total_contribs = graph.calculate_total_contributions();
344      let name = stats["name"].as_str().unwrap_or(username);
345      println!(
346        "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
347        name, total_contribs
348      );
349    } else {
350      // Use terminal width constraint for minimal layout
351      let width_constraint = self.terminal_width.saturating_sub(4);
352      let graph_lines =
353        self.get_contribution_graph_lines_with_width(username, stats, width_constraint)?;
354      for line in graph_lines {
355        println!("{}", line);
356      }
357    }
358
359    Ok(())
360  }
361
362  fn display_compact(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
363    println!();
364
365    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
366    let graph_width = ((self.terminal_width.saturating_sub(40) * 3) / 4).max(40);
367
368    // Left side: graph lines
369    let graph_lines = if !self.visual_opts.no_grid {
370      self.get_contribution_graph_lines_with_width(username, stats, graph_width)?
371    } else {
372      let total_contribs = graph.calculate_total_contributions();
373      let name = user_data["name"].as_str().unwrap_or(username);
374      vec![format!(
375        "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
376        name, total_contribs
377      )]
378    };
379
380    // Right side: compact user info + achievements (NO languages, NO PR/Issues)
381    let mut right_lines = Vec::new();
382
383    if !self.visual_opts.no_account {
384      let total_contribs = graph.calculate_total_contributions();
385      let name = user_data["name"].as_str().unwrap_or(username);
386      right_lines.push(format!(
387        "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
388        name, total_contribs
389      ));
390    }
391
392    if !self.visual_opts.no_achievements {
393      let achievement_lines = self.format_achievements(&graph);
394      if !achievement_lines.is_empty() {
395        // Python adds empty line ONLY if right_side already has content
396        if !right_lines.is_empty() {
397          right_lines.push(String::new());
398        }
399        right_lines.extend(achievement_lines);
400      }
401    }
402
403    // Display side-by-side
404    let max_lines = graph_lines.len().max(right_lines.len());
405    for i in 0..max_lines {
406      let graph_part = if i < graph_lines.len() {
407        &graph_lines[i]
408      } else {
409        ""
410      };
411      let graph_len = self.display_width(graph_part);
412      let padding = " ".repeat(graph_width.saturating_sub(graph_len));
413
414      let info_part = if i < right_lines.len() {
415        &right_lines[i]
416      } else {
417        ""
418      };
419
420      println!("{}{}  {}", graph_part, padding, info_part);
421    }
422
423    Ok(())
424  }
425
426  fn combine_section_grid(&self, columns: &[Vec<String>], width_limit: usize) -> Vec<String> {
427    let active_columns: Vec<&Vec<String>> = columns.iter().filter(|col| !col.is_empty()).collect();
428
429    if active_columns.is_empty() {
430      return vec![];
431    }
432
433    let indent = "    ";
434    let gap = "   ";
435    let gap_width = gap.len();
436    let indent_width = indent.len();
437
438    // Calculate column info: (column, max_width)
439    let column_info: Vec<(&Vec<String>, usize)> = active_columns
440      .iter()
441      .map(|col| {
442        let max_width = col
443          .iter()
444          .map(|line| self.display_width(line))
445          .max()
446          .unwrap_or(0);
447        (*col, max_width)
448      })
449      .collect();
450
451    // Split columns into rows based on width_limit
452    let mut rows: Vec<Vec<(&Vec<String>, usize)>> = vec![];
453    let mut current_row: Vec<(&Vec<String>, usize)> = vec![];
454    let mut current_width = indent_width;
455
456    for (col, width) in column_info {
457      let projected = if current_row.is_empty() {
458        width
459      } else {
460        width + gap_width
461      };
462
463      if !current_row.is_empty() && current_width + projected > width_limit {
464        rows.push(current_row);
465        current_row = vec![];
466        current_width = indent_width;
467      }
468
469      if current_row.is_empty() {
470        current_width += width;
471      } else {
472        current_width += gap_width + width;
473      }
474
475      current_row.push((col, width));
476    }
477
478    if !current_row.is_empty() {
479      rows.push(current_row);
480    }
481
482    // Build combined lines
483    let mut combined = vec![];
484    for (row_idx, row) in rows.iter().enumerate() {
485      let max_lines = row.iter().map(|(col, _)| col.len()).max().unwrap_or(0);
486
487      for line_idx in 0..max_lines {
488        let mut parts = vec![];
489        for (col_idx, (col, width)) in row.iter().enumerate() {
490          let text = if line_idx < col.len() {
491            &col[line_idx]
492          } else {
493            ""
494          };
495          let text_width = self.display_width(text);
496          let pad_width = width.saturating_sub(text_width);
497          let pad = " ".repeat(pad_width);
498          let spacer = if col_idx < row.len() - 1 { gap } else { "" };
499          parts.push(format!("{}{}{}", text, pad, spacer));
500        }
501
502        combined.push(format!("{}{}", indent, parts.join("").trim_end()));
503      }
504
505      if row_idx < rows.len() - 1 {
506        combined.push(String::new());
507      }
508    }
509
510    combined
511  }
512
513  fn display_full(&self, username: &str, user_data: &Value, stats: &Value) -> Result<()> {
514    println!();
515
516    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
517    let total_contribs = graph.calculate_total_contributions();
518
519    // Calculate graph width constraint (matching Python)
520    let graph_width = self
521      .visual_opts
522      .width
523      .unwrap_or_else(|| ((self.terminal_width.saturating_sub(10).max(50) * 3) / 4).max(50));
524
525    // Left: contribution graph + PR/Issues below (only if --no-grid is not set)
526    let mut graph_lines = if !self.visual_opts.no_grid {
527      self.get_contribution_graph_lines_with_width(username, stats, graph_width)?
528    } else {
529      vec![]
530    };
531
532    // Add PR/Issues sections to left side (below graph) if enabled
533    if !self.visual_opts.no_pr || !self.visual_opts.no_issues {
534      let pr_lines = if !self.visual_opts.no_pr {
535        self.format_pull_requests(stats)
536      } else {
537        vec![]
538      };
539
540      let issue_lines = if !self.visual_opts.no_issues {
541        self.format_issues(stats)
542      } else {
543        vec![]
544      };
545
546      // Check if PR and Issues can fit side-by-side within graph_width
547      let section_columns: Vec<Vec<String>> = if !pr_lines.is_empty() && !issue_lines.is_empty() {
548        let pr_width = pr_lines
549          .iter()
550          .map(|l| self.display_width(l))
551          .max()
552          .unwrap_or(0);
553        let issue_width = issue_lines
554          .iter()
555          .map(|l| self.display_width(l))
556          .max()
557          .unwrap_or(0);
558        let total_width = pr_width + issue_width + 3; // gap
559
560        if total_width <= graph_width {
561          vec![pr_lines, issue_lines]
562        } else {
563          // If both exist but don't fit side-by-side, don't show either (matching Python)
564          vec![]
565        }
566      } else if !pr_lines.is_empty() {
567        vec![pr_lines]
568      } else if !issue_lines.is_empty() {
569        vec![issue_lines]
570      } else {
571        vec![]
572      };
573
574      // Combine sections side-by-side
575      if !section_columns.is_empty() {
576        if !graph_lines.is_empty() {
577          graph_lines.push(String::new()); // Add spacing
578        }
579
580        let combined = self.combine_section_grid(&section_columns, graph_width);
581        graph_lines.extend(combined);
582      }
583    }
584
585    // Right: user information
586    let mut right_lines = vec![];
587
588    if !self.visual_opts.no_account {
589      right_lines.extend(self.format_user_info(username, user_data, stats, total_contribs));
590    }
591
592    // Only show languages if terminal width >= 120 (matching Python behavior)
593    if !self.visual_opts.no_languages && self.terminal_width >= 120 {
594      let language_lines = self.format_languages(stats);
595      if !language_lines.is_empty() {
596        if !right_lines.is_empty() {
597          right_lines.push(String::new());
598        }
599        right_lines.extend(language_lines);
600      }
601    }
602
603    if !self.visual_opts.no_achievements {
604      let achievement_lines = self.format_achievements(&graph);
605      if !achievement_lines.is_empty() {
606        if !right_lines.is_empty() {
607          right_lines.push(String::new());
608        }
609        right_lines.extend(achievement_lines);
610      }
611    }
612
613    // Side-by-side output
614    let max_left_width = graph_lines
615      .iter()
616      .map(|l| self.display_width(l))
617      .max()
618      .unwrap_or(0);
619
620    let max_lines = graph_lines.len().max(right_lines.len());
621    for i in 0..max_lines {
622      let left = if i < graph_lines.len() {
623        &graph_lines[i]
624      } else {
625        ""
626      };
627      let left_width = self.display_width(left);
628      let padding = " ".repeat(max_left_width.saturating_sub(left_width));
629
630      let right = if i < right_lines.len() {
631        &right_lines[i]
632      } else {
633        ""
634      };
635
636      println!("{}{}  {}", left, padding, right);
637    }
638
639    Ok(())
640  }
641
642  fn get_contribution_graph_lines_with_width(
643    &self,
644    _username: &str,
645    stats: &Value,
646    width_constraint: usize,
647  ) -> Result<Vec<String>> {
648    let graph = ContributionGraph::from_json(&stats["contribution_graph"]);
649    let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
650    let show_date = self.config.show_date;
651    let spaced = self.visual_opts.spaced;
652
653    // Calculate max weeks that fit in width_constraint
654    // Each week is 2 chars wide (■ ), plus 4 char margin
655    let block_width = 2;
656    let header_margin = 4;
657    let available_for_graph = width_constraint.saturating_sub(header_margin);
658    let max_weeks = (available_for_graph / block_width).max(13).min(52);
659
660    // Use calculated weeks as width
661    let lines = graph.render(
662      Some(max_weeks),
663      self.visual_opts.height,
664      custom_box,
665      &self.config.colors,
666      show_date,
667      spaced,
668    );
669
670    Ok(lines)
671  }
672
673  pub fn display_simulation_from_grid(&self, grid: Vec<Vec<u8>>) -> Result<()> {
674    let graph = ContributionGraph::from_grid(grid);
675    let custom_box = self.config.custom_box.as_deref().unwrap_or("■");
676    let show_date = false; // No date labels for simulations
677    let spaced = self.visual_opts.spaced;
678
679    let lines = graph.render(
680      None, // Use full width
681      None, // Use full height (7 days)
682      custom_box,
683      &self.config.colors,
684      show_date,
685      spaced,
686    );
687
688    for line in lines {
689      println!("{}", line);
690    }
691
692    Ok(())
693  }
694
695  fn format_user_info(
696    &self,
697    _username: &str,
698    user_data: &Value,
699    stats: &Value,
700    total_contribs: u32,
701  ) -> Vec<String> {
702    let mut lines = Vec::new();
703
704    let name = user_data["name"].as_str().unwrap_or("Unknown");
705    let header = format!(
706      "\x1b[38;2;118;215;161m{}\x1b[0m - \x1b[38;2;255;184;108m{}\x1b[0m \x1b[38;2;118;215;161mcontributions this year\x1b[0m",
707      name, total_contribs
708    );
709    lines.push(header);
710
711    let plain = format!("{} - {} contributions this year", name, total_contribs);
712    lines.push(self.colorize(&"─".repeat(plain.len()), "muted"));
713
714    if let Some(bio) = user_data["bio"].as_str() {
715      if !bio.is_empty() {
716        let trimmed = bio.replace('\n', " ");
717        let truncated = if trimmed.len() > 80 {
718          &trimmed[..80]
719        } else {
720          &trimmed
721        };
722        lines.push(format!("{} {}", self.label("Bio"), truncated));
723      }
724    }
725
726    if let Some(company) = user_data["company"].as_str() {
727      if !company.is_empty() {
728        lines.push(format!("{} {}", self.label("Company"), company));
729      }
730    }
731
732    if let Some(blog) = user_data["blog"].as_str() {
733      if !blog.is_empty() {
734        lines.push(format!("{} {}", self.label("Website"), blog));
735      }
736    }
737
738    // Add stars amount
739    if let Some(total_stars) = stats["total_stars"].as_i64() {
740      lines.push(format!("{} {} ⭐", self.label("Stars"), total_stars));
741    }
742
743    lines
744  }
745
746  fn format_languages(&self, stats: &Value) -> Vec<String> {
747    let mut lines = Vec::new();
748
749    let languages = match stats["languages"].as_object() {
750      Some(langs) if !langs.is_empty() => langs,
751      _ => return lines,
752    };
753
754    lines.push(self.colorize("TOP LANGUAGES", "header"));
755    lines.push(self.colorize(&"─".repeat(13), "muted"));
756
757    let mut lang_vec: Vec<_> = languages.iter().collect();
758    lang_vec.sort_by(|a, b| {
759      let a_val = a.1.as_f64().unwrap_or(0.0);
760      let b_val = b.1.as_f64().unwrap_or(0.0);
761      b_val.partial_cmp(&a_val).unwrap()
762    });
763
764    for (lang, percentage) in lang_vec.iter().take(5) {
765      let pct = percentage.as_f64().unwrap_or(0.0);
766      let lang_name = if lang.to_lowercase() == "jupyter notebook" {
767        "Jupyter"
768      } else {
769        lang
770      };
771
772      let bar = self.render_progress_bar(pct, 24);
773      lines.push(format!("{} {} {:5.1}%", self.label(lang_name), bar, pct));
774    }
775
776    lines
777  }
778
779  fn format_achievements(&self, graph: &ContributionGraph) -> Vec<String> {
780    let mut lines = Vec::new();
781
782    let (current_streak, max_streak) = graph.calculate_streaks();
783    let total_contribs = graph.calculate_total_contributions();
784
785    // Build achievement entries
786    let mut entries = Vec::new();
787
788    if current_streak > 0 {
789      let streak_text = if current_streak == 1 {
790        format!("{} day", current_streak)
791      } else {
792        format!("{} days", current_streak)
793      };
794      entries.push((
795        format!("{} Current Streak", self.colorize("🔥", "red")),
796        streak_text,
797      ));
798    }
799
800    if max_streak > 0 {
801      let streak_text = if max_streak == 1 {
802        format!("{} day", max_streak)
803      } else {
804        format!("{} days", max_streak)
805      };
806      entries.push((
807        format!("{} Best Streak", self.colorize("⭐", "yellow")),
808        streak_text,
809      ));
810    }
811
812    if total_contribs >= 10000 {
813      entries.push((
814        format!("{} Contributions", self.colorize("💎", "magenta")),
815        "10k+".to_string(),
816      ));
817    } else if total_contribs >= 5000 {
818      entries.push((
819        format!("{} Contributions", self.colorize("👑", "yellow")),
820        "5k+".to_string(),
821      ));
822    } else if total_contribs >= 1000 {
823      entries.push((
824        format!("{} Contributions", self.colorize("🎖️", "cyan")),
825        "1k+".to_string(),
826      ));
827    } else if total_contribs >= 100 {
828      entries.push((
829        format!("{} Contributions", self.colorize("🏆", "yellow")),
830        "100+".to_string(),
831      ));
832    }
833
834    if !entries.is_empty() {
835      let title = "ACHIEVEMENTS";
836      lines.push(self.colorize(title, "header"));
837      lines.push(self.colorize(&"─".repeat(title.len()), "muted"));
838
839      // Calculate max label width (without ANSI codes)
840      let label_width = entries
841        .iter()
842        .map(|(label, _)| self.display_width(label))
843        .max()
844        .unwrap_or(0);
845
846      for (label, value) in entries {
847        let label_len = self.display_width(&label);
848        let padding = " ".repeat(label_width.saturating_sub(label_len));
849        lines.push(format!("{}{}  {}", label, padding, value));
850      }
851    }
852
853    lines
854  }
855
856  fn render_progress_bar(&self, percentage: f64, width: usize) -> String {
857    let width = width.max(1);
858    let capped = percentage.max(0.0).min(100.0);
859    let filled = ((capped / 100.0) * width as f64).round() as usize;
860    let filled = filled.min(width);
861    let empty = width - filled;
862
863    let filled_segment = "▰".repeat(filled);
864    let empty_segment = "▱".repeat(empty);
865
866    let colored_filled = self.colorize(&filled_segment, "green");
867
868    format!("{}{}", colored_filled, empty_segment)
869  }
870
871  fn label(&self, text: &str) -> String {
872    let label = format!("{}:", text);
873    let padded = format!("{:<12}", label);
874    self.colorize(&padded, "bold")
875  }
876
877  fn colorize(&self, text: &str, color: &str) -> String {
878    let color_code = match color {
879      "header" => "\x1b[38;2;118;215;161m",
880      "orange" => "\x1b[38;2;255;184;108m",
881      "green" => "\x1b[38;2;80;250;123m",
882      "muted" => "\x1b[38;2;68;71;90m",
883      "bold" => "\x1b[1m",
884      "red" => "\x1b[91m",
885      "yellow" => "\x1b[93m",
886      "cyan" => "\x1b[96m",
887      "magenta" => "\x1b[95m",
888      _ => "\x1b[0m",
889    };
890
891    format!("{}{}\x1b[0m", color_code, text)
892  }
893
894  fn display_width(&self, text: &str) -> usize {
895    // Calculate actual display width after removing ANSI codes
896    let ansi_pattern = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
897    let clean = ansi_pattern.replace_all(text, "");
898
899    // Use unicode-width to properly calculate width for CJK characters
900    use unicode_width::UnicodeWidthStr;
901    clean.width()
902  }
903
904  fn truncate_text(&self, text: &str, max_width: usize) -> String {
905    if self.display_width(text) <= max_width {
906      return text.to_string();
907    }
908
909    let ellipsis = '…';
910    let mut truncated = String::new();
911    for ch in text.chars() {
912      let test = format!("{}{}{}", truncated, ch, ellipsis);
913      if self.display_width(&test) > max_width {
914        break;
915      }
916      truncated.push(ch);
917    }
918    format!("{}{}", truncated, ellipsis)
919  }
920
921  fn format_pull_requests(&self, stats: &Value) -> Vec<String> {
922    let mut lines = Vec::new();
923
924    let prs = match stats.get("pull_requests") {
925      Some(pr_data) if pr_data.is_object() => pr_data,
926      _ => return lines,
927    };
928
929    lines.push(self.colorize("PULL REQUESTS", "header"));
930    lines.push(self.colorize(&"─".repeat(13), "muted"));
931
932    // Calculate label width (matching Python: max label length + 2 for colon and space)
933    let labels = ["Awaiting Review", "Your Open PRs", "Mentions"];
934    let label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0) + 2;
935
936    for (label, key) in [
937      ("Awaiting Review", "awaiting_review"),
938      ("Your Open PRs", "open"),
939      ("Mentions", "mentions"),
940    ] {
941      let data = prs.get(key);
942      let total = data
943        .and_then(|d| d.get("total_count"))
944        .and_then(|t| t.as_i64())
945        .unwrap_or(0);
946
947      let label_text = format!("{}:", label);
948      let padded_label = format!("{:<width$}", label_text, width = label_width);
949      lines.push(format!(
950        "{} {}",
951        self.colorize(&padded_label, "header"),
952        total
953      ));
954
955      // Display items (max 3)
956      let items = data
957        .and_then(|d| d.get("items"))
958        .and_then(|i| i.as_array())
959        .map(|arr| &arr[..arr.len().min(3)])
960        .unwrap_or(&[]);
961
962      if items.is_empty() {
963        lines.push(format!("  {}", self.colorize("• None", "muted")));
964      } else {
965        for item in items {
966          let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
967          let repo = item.get("repo").and_then(|r| r.as_str()).unwrap_or("");
968
969          let mut bullet = format!("• {}", self.truncate_text(title, 24));
970          if !repo.is_empty() {
971            bullet.push_str(&format!(" ({})", self.truncate_text(repo, 16)));
972          }
973          lines.push(format!("  {}", bullet));
974        }
975      }
976    }
977
978    lines
979  }
980
981  fn format_issues(&self, stats: &Value) -> Vec<String> {
982    let mut lines = Vec::new();
983
984    let issues = match stats.get("issues") {
985      Some(issue_data) if issue_data.is_object() => issue_data,
986      _ => return lines,
987    };
988
989    lines.push(self.colorize("ISSUES", "header"));
990    lines.push(self.colorize(&"─".repeat(6), "muted"));
991
992    // Calculate label width (matching Python: max label length + 2 for colon and space)
993    let labels = ["Assigned", "Created (open)", "Mentions"];
994    let label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0) + 2;
995
996    for (label, key) in [
997      ("Assigned", "assigned"),
998      ("Created (open)", "created"),
999      ("Mentions", "mentions"),
1000    ] {
1001      let data = issues.get(key);
1002      let total = data
1003        .and_then(|d| d.get("total_count"))
1004        .and_then(|t| t.as_i64())
1005        .unwrap_or(0);
1006
1007      let label_text = format!("{}:", label);
1008      let padded_label = format!("{:<width$}", label_text, width = label_width);
1009      lines.push(format!(
1010        "{} {}",
1011        self.colorize(&padded_label, "header"),
1012        total
1013      ));
1014
1015      // Display items (max 3)
1016      let items = data
1017        .and_then(|d| d.get("items"))
1018        .and_then(|i| i.as_array())
1019        .map(|arr| &arr[..arr.len().min(3)])
1020        .unwrap_or(&[]);
1021
1022      if items.is_empty() {
1023        lines.push(format!("  {}", self.colorize("• None", "muted")));
1024      } else {
1025        for item in items {
1026          let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
1027          let repo = item.get("repo").and_then(|r| r.as_str()).unwrap_or("");
1028
1029          let mut bullet = format!("• {}", self.truncate_text(title, 24));
1030          if !repo.is_empty() {
1031            bullet.push_str(&format!(" ({})", self.truncate_text(repo, 16)));
1032          }
1033          lines.push(format!("  {}", bullet));
1034        }
1035      }
1036    }
1037
1038    lines
1039  }
1040}