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