use clap::{Parser, ValueEnum};
use crate::state::ThemeName;
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
#[clap(rename_all = "lower")]
pub enum ViewMode {
Split,
Unified,
}
#[derive(Parser, Debug)]
#[command(name = "shuire", version, about = "Vim-like TUI git diff viewer")]
pub struct Args {
#[arg(long, default_value_t = 4.0)]
pub tick_rate: f64,
#[arg(long, default_value_t = 60.0)]
pub frame_rate: f64,
#[arg(default_value = "@")]
pub target: String,
pub compare_with: Option<String>,
#[arg(long)]
pub merge_base: bool,
#[arg(long)]
pub include_untracked: bool,
#[arg(long, short = 'U')]
pub context: Option<u32>,
#[arg(long, value_enum)]
pub theme: Option<ThemeName>,
#[arg(long)]
pub pr: Option<String>,
#[arg(long, value_name = "PATH")]
pub from_file: Option<std::path::PathBuf>,
#[arg(long)]
pub comment: Vec<String>,
#[arg(long)]
pub auto_viewed: Vec<String>,
#[arg(long)]
pub no_emoji: bool,
#[arg(long, value_enum)]
pub mode: Option<ViewMode>,
#[arg(long)]
pub clean: bool,
#[arg(long, overrides_with = "no_hints")]
pub hints: bool,
#[arg(long, overrides_with = "hints")]
pub no_hints: bool,
#[arg(long)]
pub no_pr_comments: bool,
}
impl Args {
pub fn hints_cli(&self) -> Option<bool> {
if self.no_hints {
Some(false)
} else if self.hints {
Some(true)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputSource {
Git,
Pr,
FromFile,
Stdin,
}
const SPECIAL_ARGS: [&str; 3] = [".", "working", "staged"];
fn is_special(s: &str) -> bool {
SPECIAL_ARGS.contains(&s)
}
pub fn validate_commitish(commitish: &str) -> bool {
let trimmed = commitish.trim();
if trimmed.is_empty() {
return false;
}
if is_special(trimmed) {
return true;
}
let base = strip_revision_suffix(trimmed);
if base.is_empty() {
return false;
}
is_valid_commitish_base(base)
}
fn is_valid_commitish_base(base: &str) -> bool {
if base.len() >= 4 && base.len() <= 40 && base.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
if base == "HEAD" || base == "@" {
return true;
}
is_valid_branch_name(base)
}
fn strip_revision_suffix(s: &str) -> &str {
let bytes = s.as_bytes();
let mut end = bytes.len();
loop {
if end == 0 {
break;
}
let last = bytes[end - 1];
if last == b'^' || last == b'~' {
end -= 1;
continue;
}
if last.is_ascii_digit() {
let mut digit_start = end - 1;
while digit_start > 0 && bytes[digit_start - 1].is_ascii_digit() {
digit_start -= 1;
}
if digit_start == 0 {
break;
}
let op = bytes[digit_start - 1];
if op != b'^' && op != b'~' {
break;
}
end = digit_start - 1;
continue;
}
break;
}
&s[..end]
}
fn is_valid_branch_name(name: &str) -> bool {
if name.starts_with('-')
|| name.ends_with('.')
|| name.contains("..")
|| name.contains("@{")
|| name.contains("//")
|| name.starts_with('/')
|| name.ends_with('/')
|| name.ends_with(".lock")
{
return false;
}
for ch in name.chars() {
let c = ch as u32;
if matches!(ch, '~' | '^' | ':' | '?' | '*' | '[' | '\\') || c <= 0x20 || c == 0x7F {
return false;
}
}
for component in name.split('/') {
if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
return false;
}
}
true
}
pub fn validate(args: &Args, source: InputSource) -> Result<(), String> {
let has_positional = args.target != "@" || args.compare_with.is_some();
match source {
InputSource::Pr => {
if has_positional {
return Err("--pr cannot be combined with positional arguments".into());
}
if args.merge_base {
return Err("--pr cannot be combined with --merge-base".into());
}
if args.context.is_some() {
return Err("--pr cannot be combined with --context".into());
}
}
InputSource::FromFile | InputSource::Stdin => {
let label = if source == InputSource::FromFile {
"--from-file"
} else {
"stdin diff"
};
if args.merge_base {
return Err(format!("--merge-base cannot be used with {label}"));
}
if args.context.is_some() {
return Err(format!("--context cannot be used with {label}"));
}
if args.pr.is_some() {
return Err(format!("--pr cannot be used with {label}"));
}
}
InputSource::Git => {
let target = &args.target;
if let Some((a, b)) = parse_three_dot(target) {
if !validate_commitish(a) {
return Err(format!("Invalid commit-ish before `...`: {a}"));
}
if !validate_commitish(b) {
return Err(format!("Invalid commit-ish after `...`: {b}"));
}
if args.compare_with.is_some() {
return Err(
"`A...B` syntax cannot be combined with a second positional argument"
.into(),
);
}
if args.merge_base {
return Err("`A...B` syntax cannot be combined with `--merge-base`".into());
}
return Ok(());
}
if args.merge_base && args.compare_with.is_none() {
return Err("--merge-base requires a <compare-with> base".into());
}
if !validate_commitish(target) {
return Err(format!("Invalid target commit-ish: {target}"));
}
if let Some(base) = &args.compare_with
&& !validate_commitish(base)
{
return Err(format!("Invalid base commit-ish: {base}"));
}
if let Some(base) = &args.compare_with {
if is_special(base) && !(base == "staged" && target == "working") {
return Err(format!(
"Special arguments (., working, staged) are only allowed as target, not base. Got base: {base}"
));
}
if target == base {
return Err(format!("Cannot compare {target} with itself"));
}
if target == "working" && base != "staged" {
return Err(
"\"working\" shows unstaged changes and cannot be compared with another commit. Use \".\" instead to compare all uncommitted changes with a specific commit.".into()
);
}
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub enum DiffRange {
Working,
Staged,
StagedAgainst { base: String },
Uncommitted,
UncommittedAgainst { base: String },
Range { base: String, target: String },
MergeBase { base: String, target: String },
Stdin,
PullRequest {
url: String,
head_sha: Option<String>,
},
}
pub fn resolve_range(args: &Args) -> DiffRange {
if let Some((a, b)) = parse_three_dot(&args.target) {
return DiffRange::MergeBase {
base: normalize(a),
target: normalize(b),
};
}
let target = normalize(&args.target);
if let Some(base) = &args.compare_with {
let base = normalize(base);
if args.merge_base {
let (anchor, subject) = (target, base);
return DiffRange::MergeBase {
base: anchor,
target: subject,
};
}
return match target.as_str() {
"working" => DiffRange::Working, "staged" => DiffRange::StagedAgainst { base },
"." => DiffRange::UncommittedAgainst { base },
_ => DiffRange::Range { base, target },
};
}
match target.as_str() {
"working" => DiffRange::Working,
"staged" => DiffRange::Staged,
"." => DiffRange::Uncommitted,
_ => DiffRange::UncommittedAgainst { base: target },
}
}
fn normalize(s: &str) -> String {
if s == "@" {
"HEAD".to_string()
} else {
s.to_string()
}
}
pub fn parse_three_dot(s: &str) -> Option<(&str, &str)> {
let idx = s.find("...")?;
let before = s[..idx].trim();
let after = s[idx + 3..].trim();
if after.starts_with('.') {
return None;
}
if before.is_empty() || after.is_empty() {
return None;
}
Some((before, after))
}
impl DiffRange {
pub fn label(&self) -> String {
match self {
DiffRange::Working => "working".to_string(),
DiffRange::Staged => "staged".to_string(),
DiffRange::StagedAgainst { base } => format!("staged..{base}"),
DiffRange::Uncommitted => "HEAD..".to_string(),
DiffRange::UncommittedAgainst { base } => format!("{base}..(uncommitted)"),
DiffRange::Range { base, target } => format!("{base}..{target}"),
DiffRange::MergeBase { base, target } => {
format!("merge-base({base},{target})..{target}")
}
DiffRange::Stdin => "stdin".to_string(),
DiffRange::PullRequest { url, .. } => format!("PR: {url}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_args() -> Args {
Args {
tick_rate: 4.0,
frame_rate: 60.0,
target: "@".to_string(),
compare_with: None,
merge_base: false,
include_untracked: false,
context: None,
theme: None,
pr: None,
from_file: None,
comment: Vec::new(),
auto_viewed: Vec::new(),
no_emoji: false,
mode: None,
clean: false,
hints: false,
no_hints: false,
no_pr_comments: false,
}
}
#[test]
fn validate_commitish_accepts_special() {
assert!(validate_commitish("."));
assert!(validate_commitish("working"));
assert!(validate_commitish("staged"));
assert!(validate_commitish("@"));
assert!(validate_commitish("HEAD"));
}
#[test]
fn validate_commitish_accepts_sha() {
assert!(validate_commitish("abcd"));
assert!(validate_commitish("abcdef1234567890"));
assert!(validate_commitish(
"abcdef1234567890abcdef1234567890abcdef12"
));
}
#[test]
fn validate_commitish_accepts_branch_names() {
assert!(validate_commitish("master"));
assert!(validate_commitish("feature/foo"));
assert!(validate_commitish("release-1.0"));
assert!(validate_commitish("users/alice/wip"));
}
#[test]
fn validate_commitish_accepts_revision_suffixes() {
assert!(validate_commitish("HEAD~1"));
assert!(validate_commitish("HEAD^"));
assert!(validate_commitish("HEAD^^"));
assert!(validate_commitish("HEAD~~1"));
assert!(validate_commitish("HEAD~10"));
assert!(validate_commitish("main^2~3"));
}
#[test]
fn validate_commitish_rejects_invalid_forms() {
assert!(!validate_commitish(""));
assert!(!validate_commitish(" "));
assert!(!validate_commitish("-foo"));
assert!(!validate_commitish("foo.."));
assert!(!validate_commitish("foo..bar"));
assert!(!validate_commitish("foo/"));
assert!(!validate_commitish("/foo"));
assert!(!validate_commitish("foo//bar"));
assert!(!validate_commitish("foo@{}"));
assert!(!validate_commitish("foo.lock"));
assert!(!validate_commitish("foo:bar"));
assert!(!validate_commitish("foo*"));
assert!(!validate_commitish("with space"));
}
#[test]
fn validate_rejects_special_as_base() {
let mut args = default_args();
args.target = "HEAD".to_string();
args.compare_with = Some("working".to_string());
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn validate_allows_staged_to_working_pairing() {
let mut args = default_args();
args.target = "working".to_string();
args.compare_with = Some("staged".to_string());
assert!(validate(&args, InputSource::Git).is_ok());
}
#[test]
fn validate_rejects_target_eq_base() {
let mut args = default_args();
args.target = "main".to_string();
args.compare_with = Some("main".to_string());
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn validate_rejects_working_vs_other_commit() {
let mut args = default_args();
args.target = "working".to_string();
args.compare_with = Some("main".to_string());
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn validate_merge_base_requires_base() {
let mut args = default_args();
args.merge_base = true;
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn validate_pr_mode_rejects_combinations() {
let mut args = default_args();
args.pr = Some("https://github.com/a/b/pull/1".to_string());
args.target = "main".to_string(); assert!(validate(&args, InputSource::Pr).is_err());
let mut args = default_args();
args.pr = Some("https://github.com/a/b/pull/1".to_string());
args.merge_base = true;
assert!(validate(&args, InputSource::Pr).is_err());
let mut args = default_args();
args.pr = Some("https://github.com/a/b/pull/1".to_string());
args.context = Some(5);
assert!(validate(&args, InputSource::Pr).is_err());
}
#[test]
fn validate_from_file_rejects_combinations() {
let mut args = default_args();
args.from_file = Some("/tmp/x.diff".into());
args.merge_base = true;
assert!(validate(&args, InputSource::FromFile).is_err());
let mut args = default_args();
args.from_file = Some("/tmp/x.diff".into());
args.pr = Some("https://github.com/a/b/pull/1".to_string());
assert!(validate(&args, InputSource::FromFile).is_err());
}
#[test]
fn resolve_range_defaults_to_working_vs_head() {
let args = default_args();
match resolve_range(&args) {
DiffRange::UncommittedAgainst { base } => assert_eq!(base, "HEAD"),
other => panic!("expected UncommittedAgainst(HEAD), got {other:?}"),
}
}
#[test]
fn resolve_range_specials() {
let mut args = default_args();
args.target = "working".to_string();
assert!(matches!(resolve_range(&args), DiffRange::Working));
let mut args = default_args();
args.target = "staged".to_string();
assert!(matches!(resolve_range(&args), DiffRange::Staged));
let mut args = default_args();
args.target = ".".to_string();
assert!(matches!(resolve_range(&args), DiffRange::Uncommitted));
}
#[test]
fn resolve_range_with_base() {
let mut args = default_args();
args.target = "feature".to_string();
args.compare_with = Some("main".to_string());
match resolve_range(&args) {
DiffRange::Range { base, target } => {
assert_eq!(base, "main");
assert_eq!(target, "feature");
}
other => panic!("expected Range, got {other:?}"),
}
}
#[test]
fn resolve_range_merge_base() {
let mut args = default_args();
args.target = "feature".to_string();
args.compare_with = Some("main".to_string());
args.merge_base = true;
match resolve_range(&args) {
DiffRange::MergeBase { base, target } => {
assert_eq!(base, "feature");
assert_eq!(target, "main");
}
other => panic!("expected MergeBase, got {other:?}"),
}
}
#[test]
fn resolve_range_merge_base_flag_matches_three_dot() {
let mut flag_args = default_args();
flag_args.target = "main".to_string();
flag_args.compare_with = Some("feature".to_string());
flag_args.merge_base = true;
let mut dot_args = default_args();
dot_args.target = "main...feature".to_string();
let (flag_base, flag_target) = match resolve_range(&flag_args) {
DiffRange::MergeBase { base, target } => (base, target),
other => panic!("expected MergeBase, got {other:?}"),
};
let (dot_base, dot_target) = match resolve_range(&dot_args) {
DiffRange::MergeBase { base, target } => (base, target),
other => panic!("expected MergeBase, got {other:?}"),
};
assert_eq!(
flag_base, dot_base,
"base mismatch between --merge-base and ... syntax"
);
assert_eq!(
flag_target, dot_target,
"target mismatch between --merge-base and ... syntax"
);
}
#[test]
fn parse_three_dot_basic() {
assert_eq!(parse_three_dot("main...feature"), Some(("main", "feature")));
assert_eq!(parse_three_dot("HEAD...feat/x"), Some(("HEAD", "feat/x")));
assert_eq!(parse_three_dot("a...b"), Some(("a", "b")));
}
#[test]
fn parse_three_dot_rejects_invalid() {
assert_eq!(parse_three_dot("a..b"), None); assert_eq!(parse_three_dot("a....b"), None); assert_eq!(parse_three_dot("...b"), None); assert_eq!(parse_three_dot("a..."), None); assert_eq!(parse_three_dot("main"), None); }
#[test]
fn resolve_range_three_dot_maps_to_merge_base() {
let mut args = default_args();
args.target = "main...feature".to_string();
match resolve_range(&args) {
DiffRange::MergeBase { base, target } => {
assert_eq!(base, "main");
assert_eq!(target, "feature");
}
other => panic!("expected MergeBase, got {other:?}"),
}
}
#[test]
fn validate_three_dot_rejects_extra_arg() {
let mut args = default_args();
args.target = "main...feature".to_string();
args.compare_with = Some("other".to_string());
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn validate_three_dot_rejects_merge_base_flag() {
let mut args = default_args();
args.target = "main...feature".to_string();
args.merge_base = true;
assert!(validate(&args, InputSource::Git).is_err());
}
#[test]
fn diff_range_labels_are_distinct() {
let labels = [
DiffRange::Working.label(),
DiffRange::Staged.label(),
DiffRange::StagedAgainst {
base: "main".into(),
}
.label(),
DiffRange::Uncommitted.label(),
DiffRange::UncommittedAgainst {
base: "main".into(),
}
.label(),
DiffRange::Range {
base: "a".into(),
target: "b".into(),
}
.label(),
DiffRange::MergeBase {
base: "a".into(),
target: "b".into(),
}
.label(),
DiffRange::Stdin.label(),
DiffRange::PullRequest {
url: "https://github.com/a/b/pull/1".into(),
head_sha: None,
}
.label(),
];
let uniq: std::collections::HashSet<_> = labels.iter().collect();
assert_eq!(uniq.len(), labels.len(), "labels collided: {labels:?}");
}
}