1use std::env;
13
14use anyhow::Result;
15use git2::Repository;
16
17use crate::event::GitEvent;
18use crate::export::{
19 bus_factor_to_json, coupling_to_json, heatmap_to_json, impact_to_json, log_to_json,
20 ownership_to_json, quality_to_json, stats_to_json, tech_debt_to_json, timeline_to_json,
21};
22use crate::git::{get_commit_files, load_events};
23use crate::stats::{
24 calculate_activity_timeline, calculate_bus_factor, calculate_change_coupling,
25 calculate_file_heatmap, calculate_impact_scores, calculate_ownership, calculate_quality_scores,
26 calculate_stats, calculate_tech_debt,
27};
28
29const MAX_LOG_LIMIT: usize = 10000;
31
32const DEFAULT_LOG_LIMIT: usize = 10;
34
35const MAX_EVENTS_FOR_STATS: usize = 2000;
37
38const _: () = {
40 assert!(MAX_LOG_LIMIT <= 10000, "MAX_LOG_LIMIT must be reasonable");
41 assert!(MAX_LOG_LIMIT > 0, "MAX_LOG_LIMIT must be positive");
42 assert!(
43 DEFAULT_LOG_LIMIT <= MAX_LOG_LIMIT,
44 "DEFAULT must not exceed MAX"
45 );
46 assert!(
47 MAX_EVENTS_FOR_STATS <= 10000,
48 "MAX_EVENTS_FOR_STATS must be reasonable"
49 );
50 assert!(
51 MAX_EVENTS_FOR_STATS > 0,
52 "MAX_EVENTS_FOR_STATS must be positive"
53 );
54};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum OutputFormat {
59 #[default]
60 Json,
61 Markdown,
62}
63
64impl OutputFormat {
65 fn from_str(s: &str) -> Option<Self> {
66 match s.to_lowercase().as_str() {
67 "json" => Some(OutputFormat::Json),
68 "md" | "markdown" => Some(OutputFormat::Markdown),
69 _ => None,
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub enum CliCommand {
77 Benchmark,
79 Stats { format: OutputFormat },
81 Heatmap { format: OutputFormat },
83 Impact { format: OutputFormat },
85 Coupling { format: OutputFormat },
87 Ownership { format: OutputFormat },
89 Quality { format: OutputFormat },
91 Timeline { format: OutputFormat },
93 BusFactor { format: OutputFormat },
95 TechDebt { format: OutputFormat },
97 Log { limit: usize, format: OutputFormat },
99 Help,
101 Version,
103}
104
105pub fn parse_cli_args() -> Option<CliCommand> {
114 let args: Vec<String> = env::args().collect();
115
116 if args.len() <= 1 {
118 return None;
119 }
120
121 let format = find_format_option(&args);
123
124 let mut i = 1;
126 while i < args.len() {
127 match args[i].as_str() {
128 "--benchmark" => return Some(CliCommand::Benchmark),
129 "--stats" => return Some(CliCommand::Stats { format }),
130 "--heatmap" => return Some(CliCommand::Heatmap { format }),
131 "--impact" => return Some(CliCommand::Impact { format }),
132 "--coupling" => return Some(CliCommand::Coupling { format }),
133 "--ownership" => return Some(CliCommand::Ownership { format }),
134 "--quality" => return Some(CliCommand::Quality { format }),
135 "--timeline" => return Some(CliCommand::Timeline { format }),
136 "--bus-factor" => return Some(CliCommand::BusFactor { format }),
137 "--tech-debt" => return Some(CliCommand::TechDebt { format }),
138 "--log" => {
139 let limit = if i + 2 < args.len() && args[i + 1] == "-n" {
141 match args[i + 2].parse::<usize>() {
143 Ok(n) if n > 0 && n <= MAX_LOG_LIMIT => n,
144 Ok(n) if n > MAX_LOG_LIMIT => {
145 eprintln!(
146 "Warning: limit {} exceeds maximum ({}), using maximum",
147 n, MAX_LOG_LIMIT
148 );
149 MAX_LOG_LIMIT
150 }
151 _ => {
152 eprintln!(
153 "Warning: invalid limit value, using default ({})",
154 DEFAULT_LOG_LIMIT
155 );
156 DEFAULT_LOG_LIMIT
157 }
158 }
159 } else {
160 DEFAULT_LOG_LIMIT
161 };
162 return Some(CliCommand::Log { limit, format });
163 }
164 "--help" | "-h" => return Some(CliCommand::Help),
165 "--version" | "-V" => return Some(CliCommand::Version),
166 _ => {}
167 }
168 i += 1;
169 }
170
171 None
172}
173
174fn find_format_option(args: &[String]) -> OutputFormat {
176 for i in 0..args.len() {
177 if args[i] == "--format" && i + 1 < args.len() {
178 if let Some(fmt) = OutputFormat::from_str(&args[i + 1]) {
179 return fmt;
180 }
181 }
182 }
183 OutputFormat::default()
184}
185
186pub fn run_cli_mode(command: CliCommand) -> Result<()> {
188 match command {
189 CliCommand::Benchmark => {
190 Ok(())
193 }
194 CliCommand::Stats { format } => run_stats(format),
195 CliCommand::Heatmap { format } => run_heatmap(format),
196 CliCommand::Impact { format } => run_impact(format),
197 CliCommand::Coupling { format } => run_coupling(format),
198 CliCommand::Ownership { format } => run_ownership(format),
199 CliCommand::Quality { format } => run_quality(format),
200 CliCommand::Timeline { format } => run_timeline(format),
201 CliCommand::BusFactor { format } => run_bus_factor(format),
202 CliCommand::TechDebt { format } => run_tech_debt(format),
203 CliCommand::Log { limit, format } => run_log(limit, format),
204 CliCommand::Help => run_help(),
205 CliCommand::Version => run_version(),
206 }
207}
208
209fn run_stats(format: OutputFormat) -> Result<()> {
211 let events = load_events_or_error()?;
212 let event_refs: Vec<&GitEvent> = events.iter().collect();
213 let stats = calculate_stats(&event_refs);
214
215 let output = match format {
216 OutputFormat::Json => stats_to_json(&stats)?,
217 OutputFormat::Markdown => stats_to_markdown(&stats),
218 };
219 println!("{}", output);
220 Ok(())
221}
222
223fn run_heatmap(format: OutputFormat) -> Result<()> {
225 let events = load_events_or_error()?;
226 let event_refs: Vec<&GitEvent> = events.iter().collect();
227 let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
228
229 let output = match format {
230 OutputFormat::Json => heatmap_to_json(&heatmap)?,
231 OutputFormat::Markdown => heatmap_to_markdown(&heatmap),
232 };
233 println!("{}", output);
234 Ok(())
235}
236
237fn run_impact(format: OutputFormat) -> Result<()> {
239 let events = load_events_or_error()?;
240 let event_refs: Vec<&GitEvent> = events.iter().collect();
241 let heatmap = calculate_file_heatmap(&event_refs, |hash| get_commit_files(hash).ok());
242 let analysis =
243 calculate_impact_scores(&event_refs, |hash| get_commit_files(hash).ok(), &heatmap);
244
245 let output = match format {
246 OutputFormat::Json => impact_to_json(&analysis)?,
247 OutputFormat::Markdown => impact_to_markdown(&analysis),
248 };
249 println!("{}", output);
250 Ok(())
251}
252
253fn run_coupling(format: OutputFormat) -> Result<()> {
255 let events = load_events_or_error()?;
256 let event_refs: Vec<&GitEvent> = events.iter().collect();
257 let analysis = calculate_change_coupling(
258 &event_refs,
259 |hash| get_commit_files(hash).ok(),
260 5, 0.3, );
263
264 let output = match format {
265 OutputFormat::Json => coupling_to_json(&analysis)?,
266 OutputFormat::Markdown => coupling_to_markdown(&analysis),
267 };
268 println!("{}", output);
269 Ok(())
270}
271
272fn run_ownership(format: OutputFormat) -> Result<()> {
274 let events = load_events_or_error()?;
275 let event_refs: Vec<&GitEvent> = events.iter().collect();
276 let ownership = calculate_ownership(&event_refs, |hash| get_commit_files(hash).ok());
277
278 let output = match format {
279 OutputFormat::Json => ownership_to_json(&ownership)?,
280 OutputFormat::Markdown => ownership_to_markdown(&ownership),
281 };
282 println!("{}", output);
283 Ok(())
284}
285
286fn run_quality(format: OutputFormat) -> Result<()> {
288 let events = load_events_or_error()?;
289 let event_refs: Vec<&GitEvent> = events.iter().collect();
290 let coupling = calculate_change_coupling(
291 &event_refs,
292 |hash| get_commit_files(hash).ok(),
293 5,
294 0.3,
295 );
296 let analysis =
297 calculate_quality_scores(&event_refs, |hash| get_commit_files(hash).ok(), &coupling);
298
299 let output = match format {
300 OutputFormat::Json => quality_to_json(&analysis)?,
301 OutputFormat::Markdown => quality_to_markdown(&analysis),
302 };
303 println!("{}", output);
304 Ok(())
305}
306
307fn run_timeline(format: OutputFormat) -> Result<()> {
309 let events = load_events_or_error()?;
310 let event_refs: Vec<&GitEvent> = events.iter().collect();
311 let timeline = calculate_activity_timeline(&event_refs);
312
313 let output = match format {
314 OutputFormat::Json => timeline_to_json(&timeline)?,
315 OutputFormat::Markdown => timeline_to_markdown(&timeline),
316 };
317 println!("{}", output);
318 Ok(())
319}
320
321fn run_bus_factor(format: OutputFormat) -> Result<()> {
323 let events = load_events_or_error()?;
324 let event_refs: Vec<&GitEvent> = events.iter().collect();
325 let analysis = calculate_bus_factor(&event_refs, |hash| get_commit_files(hash).ok(), 5);
326
327 let output = match format {
328 OutputFormat::Json => bus_factor_to_json(&analysis)?,
329 OutputFormat::Markdown => bus_factor_to_markdown(&analysis),
330 };
331 println!("{}", output);
332 Ok(())
333}
334
335fn run_tech_debt(format: OutputFormat) -> Result<()> {
337 let events = load_events_or_error()?;
338 let event_refs: Vec<&GitEvent> = events.iter().collect();
339 let analysis = calculate_tech_debt(&event_refs, |hash| get_commit_files(hash).ok(), 3);
340
341 let output = match format {
342 OutputFormat::Json => tech_debt_to_json(&analysis)?,
343 OutputFormat::Markdown => tech_debt_to_markdown(&analysis),
344 };
345 println!("{}", output);
346 Ok(())
347}
348
349fn run_log(limit: usize, format: OutputFormat) -> Result<()> {
351 let events = load_events_limited(limit)?;
352
353 let output = match format {
354 OutputFormat::Json => log_to_json(&events)?,
355 OutputFormat::Markdown => log_to_markdown(&events),
356 };
357 println!("{}", output);
358 Ok(())
359}
360
361fn run_help() -> Result<()> {
363 let help = format!(
364 r#"gitstack - Git history viewer with insights
365
366USAGE:
367 gitstack [OPTIONS]
368
369ANALYSIS OPTIONS:
370 --stats Output author statistics
371 --heatmap Output file change heatmap
372 --impact Output Impact Score (commit influence)
373 --coupling Output Change Coupling (file co-change)
374 --ownership Output Code Ownership
375 --quality Output Commit Quality Score
376 --timeline Output Activity Timeline
377 --bus-factor Output Bus Factor analysis (knowledge risk)
378 --tech-debt Output Technical Debt Score
379 --log -n N Output latest N commits (default: {}, max: {})
380
381FORMAT OPTIONS:
382 --format json Output as JSON (default)
383 --format md Output as Markdown
384
385GENERAL OPTIONS:
386 --help, -h Show this help message
387 --version, -V Show version information
388
389Without options, gitstack starts in interactive TUI mode.
390
391EXAMPLES:
392 gitstack --stats # JSON output
393 gitstack --stats --format md # Markdown output
394 gitstack --bus-factor --format json # Bus factor analysis
395 gitstack --tech-debt --format md # Tech debt as Markdown
396 gitstack --log -n 5 | jq . # Latest 5 commits
397
398For more information, visit: https://github.com/Hiro-Chiba/gitstack"#,
399 DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT
400 );
401
402 println!("{}", help);
403 Ok(())
404}
405
406fn run_version() -> Result<()> {
408 println!("gitstack {}", env!("CARGO_PKG_VERSION"));
409 Ok(())
410}
411
412fn load_events_or_error() -> Result<Vec<GitEvent>> {
414 Repository::discover(".").map_err(|_| {
415 anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
416 })?;
417
418 load_events(MAX_EVENTS_FOR_STATS).map_err(|_| {
419 anyhow::anyhow!("Error: Failed to load git history")
420 })
421}
422
423fn load_events_limited(limit: usize) -> Result<Vec<GitEvent>> {
425 Repository::discover(".").map_err(|_| {
426 anyhow::anyhow!("Error: Not a git repository (or any of the parent directories)")
427 })?;
428
429 let safe_limit = limit.min(MAX_LOG_LIMIT);
430
431 load_events(safe_limit).map_err(|_| {
432 anyhow::anyhow!("Error: Failed to load git history")
433 })
434}
435
436use crate::stats::{
441 ActivityTimeline, BusFactorAnalysis, ChangeCouplingAnalysis, CodeOwnership,
442 CommitImpactAnalysis, CommitQualityAnalysis, FileHeatmap, RepoStats, TechDebtAnalysis,
443};
444
445fn stats_to_markdown(stats: &RepoStats) -> String {
446 let mut md = String::new();
447 md.push_str("# Author Statistics\n\n");
448 md.push_str(&format!(
449 "- **Total Commits**: {}\n",
450 stats.total_commits
451 ));
452 md.push_str(&format!(
453 "- **Total Insertions**: {}\n",
454 stats.total_insertions
455 ));
456 md.push_str(&format!(
457 "- **Total Deletions**: {}\n",
458 stats.total_deletions
459 ));
460 md.push_str(&format!("- **Authors**: {}\n\n", stats.author_count()));
461
462 md.push_str("## Top Contributors\n\n");
463 md.push_str("| Author | Commits | Lines (+/-) | % |\n");
464 md.push_str("|--------|--------:|------------:|--:|\n");
465 for author in stats.authors.iter().take(20) {
466 md.push_str(&format!(
467 "| {} | {} | +{} / -{} | {:.1}% |\n",
468 author.name,
469 author.commit_count,
470 author.insertions,
471 author.deletions,
472 author.commit_percentage(stats.total_commits)
473 ));
474 }
475 md
476}
477
478fn heatmap_to_markdown(heatmap: &FileHeatmap) -> String {
479 let mut md = String::new();
480 md.push_str("# File Heatmap\n\n");
481 md.push_str(&format!("**Total Files**: {}\n\n", heatmap.total_files));
482
483 md.push_str("## Hot Files (Most Changed)\n\n");
484 md.push_str("| File | Changes | Heat |\n");
485 md.push_str("|------|--------:|:----:|\n");
486 for file in heatmap.files.iter().take(30) {
487 let heat_bar = file.heat_bar();
488 md.push_str(&format!(
489 "| `{}` | {} | {} |\n",
490 file.path, file.change_count, heat_bar
491 ));
492 }
493 md
494}
495
496fn impact_to_markdown(analysis: &CommitImpactAnalysis) -> String {
497 let mut md = String::new();
498 md.push_str("# Impact Score Analysis\n\n");
499 md.push_str(&format!(
500 "- **Total Commits**: {}\n",
501 analysis.total_commits
502 ));
503 md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
504 md.push_str(&format!(
505 "- **High Impact Commits**: {}\n\n",
506 analysis.high_impact_count
507 ));
508
509 md.push_str("## High Impact Commits\n\n");
510 md.push_str("| Hash | Author | Score | Files | Message |\n");
511 md.push_str("|------|--------|------:|------:|--------|\n");
512 for commit in analysis.commits.iter().take(20) {
513 let msg = if commit.commit_message.len() > 40 {
514 format!("{}...", &commit.commit_message[..37])
515 } else {
516 commit.commit_message.clone()
517 };
518 md.push_str(&format!(
519 "| `{}` | {} | {:.2} | {} | {} |\n",
520 commit.commit_hash, commit.author, commit.score, commit.files_changed, msg
521 ));
522 }
523 md
524}
525
526fn coupling_to_markdown(analysis: &ChangeCouplingAnalysis) -> String {
527 let mut md = String::new();
528 md.push_str("# Change Coupling Analysis\n\n");
529 md.push_str(&format!(
530 "- **Total Couplings**: {}\n",
531 analysis.couplings.len()
532 ));
533 md.push_str(&format!(
534 "- **High Coupling (>70%)**: {}\n\n",
535 analysis.high_coupling_count
536 ));
537
538 md.push_str("## File Couplings\n\n");
539 md.push_str("| File | Coupled With | Coupling | Co-Changes |\n");
540 md.push_str("|------|--------------|----------|------------|\n");
541 for coupling in analysis.couplings.iter().take(30) {
542 md.push_str(&format!(
543 "| `{}` | `{}` | {:.1}% | {} |\n",
544 coupling.file,
545 coupling.coupled_file,
546 coupling.coupling_percent * 100.0,
547 coupling.co_change_count
548 ));
549 }
550 md
551}
552
553fn ownership_to_markdown(ownership: &CodeOwnership) -> String {
554 let mut md = String::new();
555 md.push_str("# Code Ownership\n\n");
556 md.push_str(&format!("**Total Files**: {}\n\n", ownership.total_files));
557
558 md.push_str("## Directory Ownership\n\n");
559 md.push_str("| Path | Primary Owner | Ownership | Commits |\n");
560 md.push_str("|------|---------------|-----------|--------:|\n");
561 for entry in ownership.entries.iter().filter(|e| e.is_directory).take(30) {
562 md.push_str(&format!(
563 "| `{}/` | {} | {:.1}% | {} |\n",
564 entry.path,
565 entry.primary_author,
566 entry.ownership_percentage(),
567 entry.total_commits
568 ));
569 }
570 md
571}
572
573fn quality_to_markdown(analysis: &CommitQualityAnalysis) -> String {
574 let mut md = String::new();
575 md.push_str("# Commit Quality Analysis\n\n");
576 md.push_str(&format!(
577 "- **Total Commits**: {}\n",
578 analysis.total_commits
579 ));
580 md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
581 md.push_str(&format!(
582 "- **High Quality (>0.6)**: {}\n",
583 analysis.high_quality_count
584 ));
585 md.push_str(&format!(
586 "- **Low Quality (<0.4)**: {}\n\n",
587 analysis.low_quality_count
588 ));
589
590 md.push_str("## Quality Breakdown\n\n");
591 md.push_str("| Hash | Author | Score | Level | Message |\n");
592 md.push_str("|------|--------|------:|-------|--------|\n");
593 for commit in analysis.commits.iter().take(20) {
594 let msg = if commit.commit_message.len() > 40 {
595 format!("{}...", &commit.commit_message[..37])
596 } else {
597 commit.commit_message.clone()
598 };
599 md.push_str(&format!(
600 "| `{}` | {} | {:.2} | {} | {} |\n",
601 commit.commit_hash,
602 commit.author,
603 commit.score,
604 commit.quality_level(),
605 msg
606 ));
607 }
608 md
609}
610
611fn timeline_to_markdown(timeline: &ActivityTimeline) -> String {
612 let mut md = String::new();
613 md.push_str("# Activity Timeline\n\n");
614 md.push_str(&format!(
615 "- **Total Commits**: {}\n",
616 timeline.total_commits
617 ));
618 md.push_str(&format!("- **Peak Time**: {}\n\n", timeline.peak_summary()));
619
620 md.push_str("## Weekly Activity Heatmap\n\n");
621 md.push_str("```\n");
622 md.push_str("Hour: 00 03 06 09 12 15 18 21\n");
623 md.push_str(" ┌──────────────────────────\n");
624 for day in 0..7 {
625 md.push_str(&format!(" {} │ ", ActivityTimeline::day_name(day)));
626 for hour in (0..24).step_by(3) {
627 let level = timeline.heat_level(day, hour);
628 md.push_str(ActivityTimeline::heat_char(level));
629 md.push(' ');
630 }
631 md.push('\n');
632 }
633 md.push_str("```\n");
634 md
635}
636
637fn bus_factor_to_markdown(analysis: &BusFactorAnalysis) -> String {
638 let mut md = String::new();
639 md.push_str("# Bus Factor Analysis\n\n");
640 md.push_str(&format!(
641 "- **Paths Analyzed**: {}\n",
642 analysis.total_paths_analyzed
643 ));
644 md.push_str(&format!(
645 "- **High Risk (Bus Factor = 1)**: {}\n",
646 analysis.high_risk_count
647 ));
648 md.push_str(&format!(
649 "- **Medium Risk (Bus Factor = 2)**: {}\n\n",
650 analysis.medium_risk_count
651 ));
652
653 if analysis.high_risk_count > 0 {
654 md.push_str("## ⚠️ High Risk Areas\n\n");
655 md.push_str("These areas have only **1 person** with significant knowledge:\n\n");
656 md.push_str("| Path | Bus Factor | Primary Contributor | Ownership |\n");
657 md.push_str("|------|:----------:|---------------------|----------:|\n");
658 for entry in analysis.entries.iter().filter(|e| e.bus_factor == 1).take(20) {
659 if let Some(c) = entry.contributors.first() {
660 md.push_str(&format!(
661 "| `{}/` | {} | {} | {:.1}% |\n",
662 entry.path, entry.bus_factor, c.name, c.contribution_percent
663 ));
664 }
665 }
666 md.push('\n');
667 }
668
669 md.push_str("## All Areas by Risk\n\n");
670 md.push_str("| Path | Bus Factor | Risk | Top Contributors |\n");
671 md.push_str("|------|:----------:|------|------------------|\n");
672 for entry in analysis.entries.iter().take(30) {
673 let contributors: Vec<String> = entry
674 .contributors
675 .iter()
676 .take(3)
677 .map(|c| format!("{} ({:.0}%)", c.name, c.contribution_percent))
678 .collect();
679 md.push_str(&format!(
680 "| `{}/` | {} | {} | {} |\n",
681 entry.path,
682 entry.bus_factor,
683 entry.risk_level.display_name(),
684 contributors.join(", ")
685 ));
686 }
687 md
688}
689
690fn tech_debt_to_markdown(analysis: &TechDebtAnalysis) -> String {
691 let mut md = String::new();
692 md.push_str("# Technical Debt Analysis\n\n");
693 md.push_str(&format!(
694 "- **Files Analyzed**: {}\n",
695 analysis.total_files_analyzed
696 ));
697 md.push_str(&format!("- **Average Score**: {:.2}\n", analysis.avg_score));
698 md.push_str(&format!(
699 "- **High Debt Files**: {}\n\n",
700 analysis.high_debt_count
701 ));
702
703 if analysis.high_debt_count > 0 {
704 md.push_str("## ⚠️ High Debt Files\n\n");
705 md.push_str("These files have high churn and complexity:\n\n");
706 md.push_str("| File | Score | Churn | Complexity | Changes |\n");
707 md.push_str("|------|------:|------:|-----------:|--------:|\n");
708 for entry in analysis
709 .entries
710 .iter()
711 .filter(|e| e.debt_level == crate::stats::TechDebtLevel::High)
712 .take(20)
713 {
714 md.push_str(&format!(
715 "| `{}` | {:.2} | {:.2} | {:.2} | {} |\n",
716 entry.path,
717 entry.score,
718 entry.churn_score,
719 entry.complexity_score,
720 entry.change_count
721 ));
722 }
723 md.push('\n');
724 }
725
726 md.push_str("## All Files by Debt Score\n\n");
727 md.push_str("| File | Score | Level | Changes | Total Lines |\n");
728 md.push_str("|------|------:|-------|--------:|------------:|\n");
729 for entry in analysis.entries.iter().take(30) {
730 md.push_str(&format!(
731 "| `{}` | {:.2} | {} | {} | {} |\n",
732 entry.path,
733 entry.score,
734 entry.debt_level.display_name(),
735 entry.change_count,
736 entry.total_changes
737 ));
738 }
739 md
740}
741
742fn log_to_markdown(events: &[GitEvent]) -> String {
743 let mut md = String::new();
744 md.push_str("# Recent Commits\n\n");
745 md.push_str(&format!("**Showing**: {} commits\n\n", events.len()));
746
747 md.push_str("| Hash | Author | Date | Message |\n");
748 md.push_str("|------|--------|------|--------|\n");
749 for event in events {
750 let date = event.timestamp.format("%Y-%m-%d");
751 let msg = if event.message.len() > 50 {
752 format!("{}...", &event.message[..47])
753 } else {
754 event.message.clone()
755 };
756 md.push_str(&format!(
757 "| `{}` | {} | {} | {} |\n",
758 event.short_hash, event.author, date, msg
759 ));
760 }
761 md
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn test_parse_cli_args_stats() {
770 assert_eq!(
771 CliCommand::Stats {
772 format: OutputFormat::Json
773 },
774 CliCommand::Stats {
775 format: OutputFormat::Json
776 }
777 );
778 }
779
780 #[test]
781 fn test_parse_cli_args_help() {
782 assert_eq!(CliCommand::Help, CliCommand::Help);
783 }
784
785 #[test]
786 fn test_parse_cli_args_version() {
787 assert_eq!(CliCommand::Version, CliCommand::Version);
788 }
789
790 #[test]
791 fn test_parse_cli_args_log_default() {
792 let log = CliCommand::Log {
793 limit: 10,
794 format: OutputFormat::Json,
795 };
796 if let CliCommand::Log { limit, .. } = log {
797 assert_eq!(limit, 10);
798 }
799 }
800
801 #[test]
802 fn test_output_format_from_str() {
803 assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
804 assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
805 assert_eq!(OutputFormat::from_str("md"), Some(OutputFormat::Markdown));
806 assert_eq!(
807 OutputFormat::from_str("markdown"),
808 Some(OutputFormat::Markdown)
809 );
810 assert_eq!(OutputFormat::from_str("invalid"), None);
811 }
812}