use std::cell::OnceCell;
use std::cmp;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Write as _};
use std::path::PathBuf;
use std::process;
use lessify::Pager;
use git_slides::git::{self, Commit};
const STORE_FILE: &str = env!("CARGO_BIN_NAME");
const COLOR_RESET: &str = "\x1b[m";
const COLOR_FAINT: &str = "\x1b[2m";
const COLOR_YELLOW: &str = "\x1b[33m";
pub struct Cmd {
git_dir: PathBuf,
history: OnceCell<Vec<Commit>>,
}
impl Cmd {
pub fn new(git_dir: PathBuf) -> Self {
Self {
git_dir,
history: OnceCell::new(),
}
}
pub fn start(&self, ref_: Option<String>) {
if !git::is_working_directory_clean() {
eprintln!("error: Working directory contains uncommitted changes.");
process::exit(1);
}
let commit_hash = if let Some(ref_) = ref_ {
git::ref_to_commit_hash(&ref_).unwrap_or_else(|| {
eprintln!("error: Bad ref input: '{ref_}'.");
process::exit(1);
})
} else {
git::current_commit_hash().unwrap_or_else(|| {
eprintln!("error: No HEAD commit. Please provide a valid ref.");
process::exit(1);
})
};
let branch_name = git::current_branch().unwrap_or_default();
let store_file = self.store_file();
#[cfg(not(tarpaulin_include))]
{
if fs::write(store_file, format!("{branch_name}:{commit_hash}\n")).is_err() {
eprintln!("error: Cannot write '.git/{STORE_FILE}'. Aborting.");
process::exit(1);
}
}
println!("Presentation started at {commit_hash}.");
self.go(1);
}
pub fn stop(&self) {
self.ensure_presentation_is_started();
Self::stash_uncommitted_changes();
println!("Presentation stopped.");
if let Some(initial_branch) = self.get_initial_branch() {
println!("Going back to branch '{initial_branch}'.");
_ = git::checkout(&initial_branch);
} else {
let head_commit = self.get_presentation_head_commit_hash();
println!("Going back to commit {head_commit}.");
_ = git::checkout(&head_commit);
}
let store_file = self.store_file();
#[cfg(not(tarpaulin_include))]
{
if fs::remove_file(store_file).is_err() {
eprintln!("error: Cannot remove '.git/{STORE_FILE}'. Aborting.");
process::exit(1);
}
}
}
pub fn next(&self, offset: usize) {
self.ensure_presentation_is_started();
let commits = self.get_history();
let n = self.get_index_of_current_commit();
let n = n + 1 + offset;
if n >= commits.len() {
println!("You've reached the end of the presentation.");
}
self.go(cmp::min(n, commits.len()));
}
pub fn previous(&self, offset: usize) {
self.ensure_presentation_is_started();
let n = self.get_index_of_current_commit();
let n = (n + 1).saturating_sub(offset);
if n <= 1 {
println!("You're at the start of the presentation.");
}
self.go(cmp::max(n, 1));
}
pub fn go(&self, n: usize) {
self.ensure_presentation_is_started();
let commits = self.get_commits_hashes();
if n < 1 || n > commits.len() {
eprintln!("error: Bad slide index. Slide {n} does not exist.");
eprintln!("Possible values range from 1 to {}.", commits.len());
process::exit(1);
}
let go_to = commits.get(n - 1).expect("bounds checked");
Self::stash_uncommitted_changes();
if !git::checkout(go_to) {
eprintln!("error: Could not checkout {go_to}.");
process::exit(1);
}
self.status();
}
pub fn status(&self) {
const SHOW_N_PREVIOUS: usize = 2;
const SHOW_N_NEXT: usize = 3;
self.ensure_presentation_is_started();
let history = self.get_history();
let n = self.get_index_of_current_commit();
let display_from = n.saturating_sub(SHOW_N_PREVIOUS);
let display_to = std::cmp::min(n + SHOW_N_NEXT, history.len() - 1);
let slide_number_padding = history.len().to_string().len();
let mut stdout = io::stdout().lock();
if n.checked_sub(SHOW_N_PREVIOUS).is_none() {
_ = writeln!(stdout, " {COLOR_FAINT}(Start){COLOR_RESET}");
}
for i in display_from..=display_to {
let Commit { hash, title } = history.get(i).expect("bounds have been checked");
if i == n {
_ = write!(stdout, "* ");
} else {
_ = write!(stdout, " ");
}
if i < n {
_ = writeln!(
stdout,
"{COLOR_FAINT}{:>slide_number_padding$}/{} {} {title}{COLOR_RESET}",
i + 1,
history.len(),
&hash[..7],
);
} else {
_ = writeln!(
stdout,
"{:>slide_number_padding$}/{} {COLOR_YELLOW}{}{COLOR_RESET} {title}",
i + 1,
history.len(),
&hash[..7],
);
}
}
if n + SHOW_N_NEXT > history.len() - 1 {
_ = writeln!(stdout, " {COLOR_FAINT}(End){COLOR_RESET}");
}
}
pub fn list(&self) {
self.ensure_presentation_is_started();
let history = self.get_history();
let n = self.get_index_of_current_commit();
let slide_number_padding = history.len().to_string().len();
let mut out = String::with_capacity(history.len() * 72);
for i in 0..history.len() {
let Commit { hash, title } = history.get(i).expect("bounds have been checked");
if i == n {
_ = write!(out, "* ");
} else {
_ = write!(out, " ");
}
_ = writeln!(
out,
"{:>slide_number_padding$}/{} {COLOR_YELLOW}{}{COLOR_RESET} {title}",
i + 1,
history.len(),
&hash[..7],
);
}
Pager::page_or_print(&out);
}
fn ensure_presentation_is_started(&self) {
if !self.is_presentation_started() {
eprintln!(
"You need to start by '{} start'.",
env!("CARGO_BIN_NAME").replacen('-', " ", 1)
);
process::exit(1);
}
}
pub fn is_presentation_started(&self) -> bool {
let store_file = self.store_file();
store_file.is_file()
}
#[cfg(not(tarpaulin_include))] fn stash_uncommitted_changes() {
if !git::is_working_directory_clean() {
if git::stash() {
println!("Stashed uncommitted changes.");
} else {
eprintln!("error: Could not stash uncommitted changes.");
}
}
}
fn get_commits_hashes(&self) -> Vec<&String> {
let history = self.get_history();
history.iter().map(|x| &x.hash).collect()
}
fn get_history(&self) -> &Vec<Commit> {
self.history.get_or_init(|| {
let hash = self.get_presentation_head_commit_hash();
git::history_up_to_commit(&hash)
})
}
fn get_presentation_head_commit_hash(&self) -> String {
self.read_store_file()
.trim()
.split_once(':')
.expect("':' is always inserted during 'start'")
.1
.to_string()
}
fn get_initial_branch(&self) -> Option<String> {
let store = self.read_store_file();
let branch = store
.trim()
.split_once(':')
.expect("':' is always inserted during 'start'")
.0;
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
#[cfg(not(tarpaulin_include))]
fn read_store_file(&self) -> String {
let store_file = self.store_file();
let Ok(store) = fs::read_to_string(store_file) else {
eprintln!("error: Cannot read '.git/{STORE_FILE}'. Aborting.");
process::exit(1);
};
store
}
fn store_file(&self) -> PathBuf {
self.git_dir.join(STORE_FILE)
}
fn get_index_of_current_commit(&self) -> usize {
let Some(commit) = self.get_index_of_current_commit_checked() else {
eprintln!("error: Current HEAD not part of presentation.");
process::exit(1);
};
commit
}
fn get_index_of_current_commit_checked(&self) -> Option<usize> {
let hash = git::current_commit_hash()?;
let hashes = self.get_commits_hashes();
hashes.into_iter().position(|x| *x == hash)
}
}