mod self_update;
use clap::{Parser, Subcommand};
use rinkaku_core::deps::TagsResolver;
use rinkaku_core::language::language_for_path;
use rinkaku_core::pipeline::analyze_diff;
use rinkaku_core::render::{OutputFormat, render};
use std::io::BufRead;
use std::io::IsTerminal;
use std::io::Read;
use std::io::Write;
#[derive(Parser, Debug, PartialEq, Eq)]
#[command(name = "rinkaku", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, conflicts_with = "pr")]
base: Option<String>,
#[arg(long, default_value = "HEAD", conflicts_with = "pr")]
head: String,
#[arg(long)]
pr: Option<String>,
#[arg(long, value_enum, default_value_t = Format::Md)]
format: Format,
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(0..=1))]
deps: u8,
}
#[derive(Subcommand, Debug, PartialEq, Eq)]
enum Command {
SelfUpdate {
#[arg(long, short = 'y')]
yes: bool,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum Format {
Md,
Json,
}
impl From<Format> for OutputFormat {
fn from(format: Format) -> Self {
match format {
Format::Md => OutputFormat::Markdown,
Format::Json => OutputFormat::Json,
}
}
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cli = Cli::parse();
if let Some(Command::SelfUpdate { yes }) = cli.command {
return self_update::run_self_update(yes);
}
let report = if let Some(pr_arg) = &cli.pr {
let parsed = parse_pr_arg(pr_arg)?;
let number = parsed.number();
let workdir = resolve_pr_workdir(&parsed)?;
log::info!("resolving PR #{number} via gh");
let pr_info = fetch_pr_info(pr_arg.trim())?;
let cwd = workdir.as_deref();
log::info!("fetching PR #{number} head");
let head_sha = fetch_pr_head(number, cwd)?;
if head_sha != pr_info.head_ref_oid {
anyhow::bail!(
"fetched PR #{number} head ({head_sha}) does not match `gh`'s reported head \
({expected}); this usually means the PR belongs to a different repository than \
the target clone's `origin` remote, or the PR was updated between resolving it \
and fetching it — verify `origin` points at the PR's repository and re-run",
expected = pr_info.head_ref_oid,
);
}
log::info!("resolving PR #{number} base commit");
let (base_sha, used_fallback) = resolve_pr_base_sha(
&pr_info.base_ref_oid,
|oid| object_exists_locally(cwd, oid),
|| fetch_branch_head(&pr_info.base_ref_name, cwd),
|oid| fetch_oid(cwd, oid),
)?;
if used_fallback {
log::warn!(
"could not resolve PR #{number}'s base commit ({base_oid}) locally; falling \
back to the current tip of {base_branch}, which may not reproduce the original \
PR diff for a merged PR",
base_oid = pr_info.base_ref_oid,
base_branch = pr_info.base_ref_name,
);
}
run_base_pipeline(&cli, &base_sha, &head_sha, cwd)?
} else if let Some(base) = &cli.base {
run_base_pipeline(&cli, base, &cli.head, None)?
} else {
let diff_text = read_stdin_diff()?;
if diff_text.trim().is_empty() {
eprintln!("note: diff is empty, nothing to analyze");
}
let resolver = build_resolver(&cli, &diff_text, read_working_tree_file, None, None)?;
log::info!("analyzing diff");
let report = analyze_diff(
&diff_text,
read_working_tree_file,
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
)?;
if let Some(note) = garbage_input_note(&diff_text, &report) {
eprintln!("{note}");
}
report
};
let output = render(&report, cli.format.into())?;
print!("{output}");
Ok(())
}
fn resolve_pr_workdir(parsed: &PrArg) -> anyhow::Result<Option<std::path::PathBuf>> {
let PrArg::Url { owner, repo, .. } = parsed else {
return Ok(None);
};
if let Some(origin) = git_remote_origin_url(None)?
&& github_remote_matches(&origin, owner, repo)
{
log::info!("using the current directory as a clone of {owner}/{repo}");
return Ok(None);
}
let ghq_candidates = ghq_candidate_clones(owner, repo);
if let Some(discovered) = select_matching_clone(
&ghq_candidates,
|path| git_remote_origin_url(Some(path)).ok().flatten(),
owner,
repo,
) {
log::info!(
"using ghq-managed clone of {owner}/{repo} at {}",
discovered.display()
);
return Ok(Some(discovered));
}
let dir = cache_repo_dir(
std::env::var("RINKAKU_CACHE_DIR").ok().as_deref(),
std::env::var("XDG_CACHE_HOME").ok().as_deref(),
std::env::var("HOME").ok().as_deref(),
owner,
repo,
)?;
if !dir.exists() {
std::fs::create_dir_all(dir.parent().unwrap_or(&dir)).map_err(|source| {
anyhow::anyhow!(
"failed to create cache directory for {}: {source}",
dir.display()
)
})?;
log::info!(
"cloning {owner}/{repo} into cache at {} (first run against this repository)",
dir.display()
);
clone_repo_into_cache(owner, repo, &dir)?;
} else {
log::info!("using cache clone of {owner}/{repo} at {}", dir.display());
}
Ok(Some(dir))
}
fn run_base_pipeline(
cli: &Cli,
base: &str,
head: &str,
cwd: Option<&std::path::Path>,
) -> anyhow::Result<rinkaku_core::render::Report> {
log::info!("diffing {base}...{head}");
let diff_text = run_git_diff(base, head, cwd)?;
if diff_text.trim().is_empty() {
eprintln!("note: diff is empty, nothing to analyze");
return Ok(rinkaku_core::render::Report {
files: Vec::new(),
skipped: Vec::new(),
});
}
let read_file = {
let head = head.to_string();
move |path: &str| read_git_show_file(cwd, &head, path)
};
let resolver = build_resolver(cli, &diff_text, &read_file, Some(head), cwd)?;
log::info!("analyzing diff");
let report = analyze_diff(
&diff_text,
read_file,
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
)?;
if let Some(note) = garbage_input_note(&diff_text, &report) {
eprintln!("{note}");
}
Ok(report)
}
fn garbage_input_note(
diff_text: &str,
report: &rinkaku_core::render::Report,
) -> Option<&'static str> {
if diff_text.trim().is_empty() {
return None;
}
if !report.files.is_empty() || !report.skipped.is_empty() {
return None;
}
Some("note: no file changes recognized in input; expected a unified diff")
}
fn build_resolver(
cli: &Cli,
diff_text: &str,
diff_read_file: impl Fn(&str) -> std::io::Result<String>,
head: Option<&str>,
cwd: Option<&std::path::Path>,
) -> anyhow::Result<Option<TagsResolver>> {
if cli.deps == 0 {
return Ok(None);
}
let reference_names =
rinkaku_core::pipeline::collect_referenced_names(diff_text, diff_read_file)?;
let paths = list_git_files(cwd)?;
log::info!(
"building dependency index over {} tracked files",
paths.len()
);
let files: Vec<(String, String)> = match head {
Some(head) => read_git_show_files_batch(cwd, head, paths)?,
None => paths
.into_iter()
.filter_map(|path| {
read_working_tree_file(&path)
.ok()
.map(|content| (path, content))
})
.collect(),
};
Ok(Some(TagsResolver::new(
files,
language_for_path,
&reference_names,
)))
}
fn list_git_files(cwd: Option<&std::path::Path>) -> anyhow::Result<Vec<String>> {
let mut command = std::process::Command::new("git");
command.args(["ls-files"]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
anyhow::bail!(
"git ls-files failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?
.lines()
.map(str::to_string)
.collect())
}
fn read_stdin_diff() -> anyhow::Result<String> {
if std::io::stdin().is_terminal() {
anyhow::bail!(
"no diff input: pipe a diff via stdin (e.g. `gh pr diff 123 | rinkaku`) or pass --base <ref>"
);
}
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
fn run_git_diff(base: &str, head: &str, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
let range = format!("{base}...{head}");
let mut command = std::process::Command::new("git");
command.args(["diff", &range]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
anyhow::bail!(
"git diff {range} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?)
}
#[derive(Debug, PartialEq, Eq, serde::Deserialize)]
struct PrInfo {
number: u64,
#[serde(rename = "baseRefName")]
base_ref_name: String,
#[serde(rename = "baseRefOid")]
base_ref_oid: String,
#[serde(rename = "headRefOid")]
head_ref_oid: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PrArg {
Number(u64),
Url {
owner: String,
repo: String,
number: u64,
},
}
impl PrArg {
fn number(&self) -> u64 {
match self {
PrArg::Number(number) => *number,
PrArg::Url { number, .. } => *number,
}
}
}
fn parse_pr_arg(value: &str) -> anyhow::Result<PrArg> {
match value.trim().strip_prefix("https://github.com/") {
Some(rest) => {
let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
match segments.as_slice() {
[owner, repo, "pull", number, ..] => Ok(PrArg::Url {
owner: owner.to_string(),
repo: repo.to_string(),
number: parse_positive_pr_number(number, value)?,
}),
_ => anyhow::bail!(
"--pr URL must look like https://github.com/<owner>/<repo>/pull/<number>, \
got: {value}"
),
}
}
None => Ok(PrArg::Number(parse_positive_pr_number(
value.trim(),
value,
)?)),
}
}
fn parse_positive_pr_number(candidate: &str, original_value: &str) -> anyhow::Result<u64> {
let number: u64 = candidate.parse().map_err(|_| {
anyhow::anyhow!("--pr must be a PR number or a GitHub PR URL, got: {original_value}")
})?;
if number == 0 {
anyhow::bail!("--pr must be a positive PR number, got: {original_value}");
}
Ok(number)
}
fn parse_github_remote(url: &str) -> Option<(String, String)> {
let url = url.trim();
let rest = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("ssh://git@github.com/"))
.or_else(|| url.strip_prefix("git@github.com:"))?;
let rest = rest.strip_suffix(".git").unwrap_or(rest);
let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
match segments.as_slice() {
[owner, repo] => Some((owner.to_string(), repo.to_string())),
_ => None,
}
}
fn github_remote_matches(remote_url: &str, owner: &str, repo: &str) -> bool {
match parse_github_remote(remote_url) {
Some((remote_owner, remote_repo)) => {
remote_owner.eq_ignore_ascii_case(owner) && remote_repo.eq_ignore_ascii_case(repo)
}
None => false,
}
}
fn cache_repo_dir(
rinkaku_cache_dir: Option<&str>,
xdg_cache_home: Option<&str>,
home: Option<&str>,
owner: &str,
repo: &str,
) -> anyhow::Result<std::path::PathBuf> {
let root = if let Some(dir) = rinkaku_cache_dir {
std::path::PathBuf::from(dir)
} else if let Some(dir) = xdg_cache_home {
std::path::PathBuf::from(dir).join("rinkaku")
} else if let Some(dir) = home {
std::path::PathBuf::from(dir).join(".cache").join("rinkaku")
} else {
anyhow::bail!(
"cannot determine a cache directory for --pr: set $RINKAKU_CACHE_DIR, \
$XDG_CACHE_HOME, or $HOME"
);
};
Ok(root.join("repos").join("github.com").join(owner).join(repo))
}
fn parse_pr_view_json(json: &str) -> anyhow::Result<PrInfo> {
Ok(serde_json::from_str(json)?)
}
fn fetch_pr_info(arg: &str) -> anyhow::Result<PrInfo> {
let output = std::process::Command::new("gh")
.args([
"pr",
"view",
arg,
"--json",
"number,baseRefName,baseRefOid,headRefOid",
])
.output()?;
if !output.status.success() {
anyhow::bail!(
"gh pr view {arg} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
parse_pr_view_json(&String::from_utf8(output.stdout)?)
}
fn fetch_pr_head(number: u64, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
run_git_fetch(&format!("refs/pull/{number}/head"), cwd)
}
fn fetch_branch_head(name: &str, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
run_git_fetch(name, cwd)
}
fn resolve_pr_base_sha(
base_ref_oid: &str,
mut object_exists: impl FnMut(&str) -> bool,
mut fetch_base_branch: impl FnMut() -> anyhow::Result<String>,
mut fetch_oid: impl FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<(String, bool)> {
if object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
let branch_tip = match fetch_base_branch() {
Ok(tip) => {
if object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
Some(tip)
}
Err(source) => {
log::warn!(
"fetching the base branch failed, continuing the base-commit resolution \
cascade: {source}"
);
None
}
};
if fetch_oid(base_ref_oid).is_ok() && object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
let branch_tip = match branch_tip {
Some(tip) => tip,
None => fetch_base_branch()?,
};
Ok((branch_tip, true))
}
fn object_exists_locally(cwd: Option<&std::path::Path>, oid: &str) -> bool {
let mut command = std::process::Command::new("git");
command.args(["cat-file", "-e", &format!("{oid}^{{commit}}")]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.output().is_ok_and(|output| output.status.success())
}
fn fetch_oid(cwd: Option<&std::path::Path>, oid: &str) -> anyhow::Result<()> {
let mut command = std::process::Command::new("git");
command.args(["fetch", "origin", oid]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
anyhow::bail!(
"git fetch origin {oid} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn run_git_fetch(refspec: &str, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
let mut fetch_command = std::process::Command::new("git");
fetch_command.args(["fetch", "origin", refspec]);
if let Some(cwd) = cwd {
fetch_command.current_dir(cwd);
}
let fetch_output = fetch_command.output()?;
if !fetch_output.status.success() {
anyhow::bail!(
"git fetch origin {refspec} failed: {}",
String::from_utf8_lossy(&fetch_output.stderr)
);
}
let mut rev_parse_command = std::process::Command::new("git");
rev_parse_command.args(["rev-parse", "FETCH_HEAD"]);
if let Some(cwd) = cwd {
rev_parse_command.current_dir(cwd);
}
let rev_parse_output = rev_parse_command.output()?;
if !rev_parse_output.status.success() {
anyhow::bail!(
"git rev-parse FETCH_HEAD failed after fetching {refspec}: {}",
String::from_utf8_lossy(&rev_parse_output.stderr)
);
}
Ok(String::from_utf8(rev_parse_output.stdout)?
.trim()
.to_string())
}
fn git_remote_origin_url(cwd: Option<&std::path::Path>) -> anyhow::Result<Option<String>> {
let mut command = std::process::Command::new("git");
command.args(["remote", "get-url", "origin"]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8(output.stdout)?.trim().to_string()))
}
fn clone_repo_into_cache(owner: &str, repo: &str, dir: &std::path::Path) -> anyhow::Result<()> {
let slug = format!("{owner}/{repo}");
let output = std::process::Command::new("gh")
.args([
"repo",
"clone",
&slug,
&dir.to_string_lossy(),
"--",
"--filter=blob:none",
])
.output()?;
if !output.status.success() {
anyhow::bail!(
"gh repo clone {slug} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn parse_ghq_list_output(stdout: &str) -> Vec<std::path::PathBuf> {
stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(std::path::PathBuf::from)
.collect()
}
fn ghq_candidate_clones(owner: &str, repo: &str) -> Vec<std::path::PathBuf> {
let slug = format!("{owner}/{repo}");
let output = match std::process::Command::new("ghq")
.args(["list", "--full-path", "--exact", &slug])
.output()
{
Ok(output) => output,
Err(source) => {
log::debug!("ghq not runnable, falling back to cache for {slug}: {source}");
return Vec::new();
}
};
if !output.status.success() {
log::debug!(
"ghq list {slug} exited non-zero, falling back to cache: {}",
String::from_utf8_lossy(&output.stderr)
);
return Vec::new();
}
match String::from_utf8(output.stdout) {
Ok(stdout) => parse_ghq_list_output(&stdout),
Err(source) => {
log::debug!(
"ghq list {slug} produced non-UTF-8 output, falling back to cache: {source}"
);
Vec::new()
}
}
}
fn select_matching_clone(
candidates: &[std::path::PathBuf],
origin_of: impl Fn(&std::path::Path) -> Option<String>,
owner: &str,
repo: &str,
) -> Option<std::path::PathBuf> {
candidates
.iter()
.find(|candidate| {
origin_of(candidate).is_some_and(|origin| github_remote_matches(&origin, owner, repo))
})
.cloned()
}
fn read_working_tree_file(path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
fn read_git_show_file(
cwd: Option<&std::path::Path>,
head: &str,
path: &str,
) -> std::io::Result<String> {
let object = format!("{head}:{path}");
let mut command = std::process::Command::new("git");
command.args(["show", &object]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"git show {object} failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
String::from_utf8(output.stdout)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
fn read_git_show_files_batch(
cwd: Option<&std::path::Path>,
head: &str,
paths: Vec<String>,
) -> anyhow::Result<Vec<(String, String)>> {
let mut command = std::process::Command::new("git");
command
.args(["cat-file", "--batch"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let mut child = command
.spawn()
.map_err(|source| anyhow::anyhow!("failed to start git cat-file --batch: {source}"))?;
let mut stdin = child
.stdin
.take()
.expect("stdin is piped, so it must be present");
let stdout = child
.stdout
.take()
.expect("stdout is piped, so it must be present");
let stderr = child
.stderr
.take()
.expect("stderr is piped, so it must be present");
let mut reader = std::io::BufReader::new(stdout);
let stderr_reader = std::thread::spawn(move || {
let mut stderr = stderr;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf);
buf
});
let mut files = Vec::with_capacity(paths.len());
for path in paths {
let object = format!("{head}:{path}");
writeln!(stdin, "{object}").map_err(|source| {
anyhow::anyhow!("failed to write to git cat-file --batch: {source}")
})?;
stdin.flush().map_err(|source| {
anyhow::anyhow!("failed to flush git cat-file --batch stdin: {source}")
})?;
match read_cat_file_batch_response(&mut reader, &object)? {
Some(content) => files.push((path, content)),
None => continue,
}
}
drop(stdin);
let status = child
.wait()
.map_err(|source| anyhow::anyhow!("failed to wait on git cat-file --batch: {source}"))?;
let stderr_output = stderr_reader
.join()
.unwrap_or_else(|_| b"<failed to read stderr: reader thread panicked>".to_vec());
if !status.success() {
anyhow::bail!(
"git cat-file --batch exited with {status}: {}",
String::from_utf8_lossy(&stderr_output)
);
}
Ok(files)
}
fn read_cat_file_batch_response(
reader: &mut impl BufRead,
object: &str,
) -> anyhow::Result<Option<String>> {
let mut header = String::new();
reader.read_line(&mut header).map_err(|source| {
anyhow::anyhow!("failed to read git cat-file --batch header: {source}")
})?;
let header = header.trim_end_matches('\n');
let Some(size) = header
.rsplit(' ')
.next()
.and_then(|s| s.parse::<usize>().ok())
else {
return Ok(None);
};
let mut content = vec![0u8; size];
reader.read_exact(&mut content).map_err(|source| {
anyhow::anyhow!("failed to read git cat-file --batch content for {object}: {source}")
})?;
let mut trailing_newline = [0u8; 1];
reader.read_exact(&mut trailing_newline).map_err(|source| {
anyhow::anyhow!(
"failed to read git cat-file --batch trailing newline for {object}: {source}"
)
})?;
match String::from_utf8(content) {
Ok(content) => Ok(Some(content)),
Err(_) => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn should_default_to_markdown_head_and_no_base_when_no_args_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_base_when_base_flag_given() {
let expected = Cli {
command: None,
base: Some("main".to_string()),
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "--base", "main"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_base_and_head_when_both_flags_given() {
let expected = Cli {
command: None,
base: Some("main".to_string()),
head: "feature-branch".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "--base", "main", "--head", "feature-branch"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_format_json_when_format_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Json,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "--format", "json"]);
assert_eq!(expected, actual);
}
#[test]
fn should_reject_unknown_format_value() {
let actual = Cli::try_parse_from(["rinkaku", "--format", "yaml"]);
assert!(actual.is_err());
}
#[test]
fn should_set_deps_zero_when_deps_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 0,
};
let actual = Cli::parse_from(["rinkaku", "--deps", "0"]);
assert_eq!(expected, actual);
}
#[test]
fn should_reject_deps_value_outside_zero_or_one() {
let actual = Cli::try_parse_from(["rinkaku", "--deps", "2"]);
assert!(actual.is_err());
}
#[test]
fn should_set_self_update_command_when_self_update_subcommand_given() {
let expected = Cli {
command: Some(Command::SelfUpdate { yes: false }),
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "self-update"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_yes_flag_when_self_update_yes_flag_given() {
let expected = Cli {
command: Some(Command::SelfUpdate { yes: true }),
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "self-update", "--yes"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_yes_flag_when_self_update_short_y_flag_given() {
let expected = Cli {
command: Some(Command::SelfUpdate { yes: true }),
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "self-update", "-y"]);
assert_eq!(expected, actual);
}
#[test]
fn should_verify_cli_definition() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn should_set_pr_when_pr_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: Some("76".to_string()),
format: Format::Md,
deps: 1,
};
let actual = Cli::parse_from(["rinkaku", "--pr", "76"]);
assert_eq!(expected, actual);
}
#[test]
fn should_reject_pr_and_base_together() {
let actual = Cli::try_parse_from(["rinkaku", "--pr", "76", "--base", "main"]);
assert!(actual.is_err());
}
#[test]
fn should_reject_pr_and_explicit_head_together() {
let actual = Cli::try_parse_from(["rinkaku", "--pr", "76", "--head", "feature-branch"]);
assert!(actual.is_err());
}
#[rstest]
#[case::should_parse_bare_number("76", PrArg::Number(76))]
#[case::should_parse_number_with_surrounding_whitespace(" 76 ", PrArg::Number(76))]
#[case::should_parse_pull_url(
"https://github.com/octocat/hello-world/pull/123",
PrArg::Url {
owner: "octocat".to_string(),
repo: "hello-world".to_string(),
number: 123,
}
)]
#[case::should_parse_pull_url_with_trailing_slash(
"https://github.com/octocat/hello-world/pull/123/",
PrArg::Url {
owner: "octocat".to_string(),
repo: "hello-world".to_string(),
number: 123,
}
)]
#[case::should_parse_pull_url_with_extra_path_segment(
"https://github.com/octocat/hello-world/pull/123/files",
PrArg::Url {
owner: "octocat".to_string(),
repo: "hello-world".to_string(),
number: 123,
}
)]
fn should_parse_pr_arg_when_input_is_valid(#[case] input: &str, #[case] expected: PrArg) {
let actual = parse_pr_arg(input).expect("expected a valid PR arg");
assert_eq!(expected, actual);
}
#[rstest]
#[case::should_reject_empty_string("")]
#[case::should_reject_non_numeric_string("abc")]
#[case::should_reject_zero("0")]
#[case::should_reject_negative_number("-1")]
#[case::should_reject_non_pull_github_url("https://github.com/octocat/hello-world/issues/123")]
#[case::should_reject_github_url_missing_number("https://github.com/octocat/hello-world/pull/")]
#[case::should_reject_unrelated_url("https://example.com/pull/123")]
fn should_reject_pr_arg_when_input_is_invalid(#[case] input: &str) {
let actual = parse_pr_arg(input);
assert!(actual.is_err(), "expected an error for input: {input}");
}
#[rstest]
#[case::should_parse_https_url(
"https://github.com/octocat/hello-world",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_parse_https_url_with_dot_git_suffix(
"https://github.com/octocat/hello-world.git",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_parse_scp_like_ssh_url(
"git@github.com:octocat/hello-world.git",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_parse_scp_like_ssh_url_without_dot_git_suffix(
"git@github.com:octocat/hello-world",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_parse_explicit_ssh_url(
"ssh://git@github.com/octocat/hello-world.git",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_parse_explicit_ssh_url_without_dot_git_suffix(
"ssh://git@github.com/octocat/hello-world",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_trim_surrounding_whitespace(
" https://github.com/octocat/hello-world.git \n",
Some(("octocat".to_string(), "hello-world".to_string()))
)]
#[case::should_reject_non_github_host("https://gitlab.com/octocat/hello-world.git", None)]
#[case::should_reject_url_missing_repo_segment("https://github.com/octocat", None)]
#[case::should_reject_url_with_extra_path_segment(
"https://github.com/octocat/hello-world/extra",
None
)]
#[case::should_reject_empty_string("", None)]
fn should_parse_github_remote(#[case] url: &str, #[case] expected: Option<(String, String)>) {
let actual = parse_github_remote(url);
assert_eq!(expected, actual);
}
#[rstest]
#[case::should_match_identical_owner_and_repo(
"https://github.com/octocat/hello-world.git",
"octocat",
"hello-world",
true
)]
#[case::should_match_case_insensitively(
"https://github.com/Octocat/Hello-World.git",
"octocat",
"hello-world",
true
)]
#[case::should_not_match_different_repo(
"https://github.com/octocat/hello-world.git",
"octocat",
"other-repo",
false
)]
#[case::should_not_match_different_owner(
"https://github.com/octocat/hello-world.git",
"someone-else",
"hello-world",
false
)]
#[case::should_not_match_non_github_remote(
"https://gitlab.com/octocat/hello-world.git",
"octocat",
"hello-world",
false
)]
fn should_check_github_remote_match(
#[case] remote_url: &str,
#[case] owner: &str,
#[case] repo: &str,
#[case] expected: bool,
) {
let actual = github_remote_matches(remote_url, owner, repo);
assert_eq!(expected, actual);
}
#[rstest]
#[case::should_prefer_rinkaku_cache_dir_when_set(
Some("/custom/cache"),
Some("/xdg/cache"),
Some("/home/user"),
"/custom/cache/repos/github.com/octocat/hello-world"
)]
#[case::should_fall_back_to_xdg_cache_home_when_rinkaku_cache_dir_unset(
None,
Some("/xdg/cache"),
Some("/home/user"),
"/xdg/cache/rinkaku/repos/github.com/octocat/hello-world"
)]
#[case::should_fall_back_to_home_when_neither_env_var_set(
None,
None,
Some("/home/user"),
"/home/user/.cache/rinkaku/repos/github.com/octocat/hello-world"
)]
fn should_build_cache_repo_dir(
#[case] rinkaku_cache_dir: Option<&str>,
#[case] xdg_cache_home: Option<&str>,
#[case] home: Option<&str>,
#[case] expected: &str,
) {
let actual = cache_repo_dir(
rinkaku_cache_dir,
xdg_cache_home,
home,
"octocat",
"hello-world",
)
.expect("expected a cache directory to be resolved");
assert_eq!(std::path::PathBuf::from(expected), actual);
}
#[test]
fn should_fail_to_build_cache_repo_dir_when_no_env_source_is_available() {
let actual = cache_repo_dir(None, None, None, "octocat", "hello-world");
assert!(actual.is_err());
}
#[rstest]
#[case::should_parse_single_line(
"/home/user/ghq/github.com/octocat/hello-world\n",
vec![std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world")]
)]
#[case::should_parse_multiple_lines(
"/home/user/ghq/github.com/octocat/hello-world\n/home/user/work/hello-world\n",
vec![
std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world"),
std::path::PathBuf::from("/home/user/work/hello-world"),
]
)]
#[case::should_skip_blank_lines_between_entries(
"/home/user/ghq/github.com/octocat/hello-world\n\n/home/user/work/hello-world\n",
vec![
std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world"),
std::path::PathBuf::from("/home/user/work/hello-world"),
]
)]
#[case::should_trim_surrounding_whitespace_per_line(
" /home/user/ghq/github.com/octocat/hello-world \n",
vec![std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world")]
)]
#[case::should_return_empty_vec_for_empty_string("", vec![])]
#[case::should_return_empty_vec_for_whitespace_only_string("\n\n \n", vec![])]
fn should_parse_ghq_list_output(
#[case] stdout: &str,
#[case] expected: Vec<std::path::PathBuf>,
) {
let actual = parse_ghq_list_output(stdout);
assert_eq!(expected, actual);
}
#[rstest]
#[case::should_return_first_candidate_when_it_matches(
vec!["/a", "/b"],
vec![("/a", "https://github.com/octocat/hello-world.git")],
Some(std::path::PathBuf::from("/a"))
)]
#[case::should_return_later_candidate_when_earlier_ones_mismatch(
vec!["/a", "/b", "/c"],
vec![
("/a", "https://github.com/someone-else/other-repo.git"),
("/b", "https://github.com/octocat/hello-world.git"),
],
Some(std::path::PathBuf::from("/b"))
)]
#[case::should_return_none_when_no_candidate_matches(
vec!["/a", "/b"],
vec![
("/a", "https://github.com/someone-else/other-repo.git"),
("/b", "https://gitlab.com/octocat/hello-world.git"),
],
None
)]
#[case::should_return_none_when_candidates_is_empty(vec![], vec![], None)]
#[case::should_return_none_when_origin_lookup_yields_nothing_for_any_candidate(
vec!["/a"],
vec![],
None
)]
fn should_select_matching_clone(
#[case] candidates: Vec<&str>,
#[case] origins: Vec<(&str, &str)>,
#[case] expected: Option<std::path::PathBuf>,
) {
let candidates: Vec<std::path::PathBuf> = candidates
.into_iter()
.map(std::path::PathBuf::from)
.collect();
let origin_of = |path: &std::path::Path| {
origins
.iter()
.find(|(candidate_path, _)| std::path::Path::new(candidate_path) == path)
.map(|(_, origin)| origin.to_string())
};
let actual = select_matching_clone(&candidates, origin_of, "octocat", "hello-world");
assert_eq!(expected, actual);
}
mod resolve_pr_base_sha_tests {
use super::*;
use pretty_assertions::assert_eq;
use std::cell::RefCell;
#[test]
fn should_return_base_ref_oid_when_it_already_exists_locally() {
let fetch_base_branch_calls = RefCell::new(0);
let fetch_oid_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| true,
|| {
*fetch_base_branch_calls.borrow_mut() += 1;
Ok("branch-tip-sha".to_string())
},
|_oid| {
*fetch_oid_calls.borrow_mut() += 1;
Ok(())
},
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
assert_eq!(0, *fetch_base_branch_calls.borrow());
assert_eq!(0, *fetch_oid_calls.borrow());
}
#[test]
fn should_return_base_ref_oid_when_fetching_the_base_branch_makes_it_available() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 1
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| Ok("branch-tip-sha".to_string()),
|_oid| panic!("fetch_oid must not be called when the base branch fetch sufficed"),
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_return_base_ref_oid_when_fetching_the_oid_directly_makes_it_available() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 2
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| Ok("branch-tip-sha".to_string()),
|_oid| Ok(()),
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_fall_back_to_branch_tip_when_the_oid_is_unreachable_by_any_means() {
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| Ok("branch-tip-sha".to_string()),
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
}
#[test]
fn should_fall_back_to_branch_tip_when_fetch_oid_succeeds_but_object_still_missing() {
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| Ok("branch-tip-sha".to_string()),
|_oid| Ok(()),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
}
#[test]
fn should_fall_through_to_fetch_oid_when_fetching_the_base_branch_fails() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 1
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| anyhow::bail!("simulated: base branch was deleted"),
|_oid| Ok(()),
)
.expect("a step-2 failure must not abort the cascade");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_fetch_branch_tip_for_fallback_when_step_two_failed_and_fetch_oid_also_fails() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
let mut calls = fetch_base_branch_calls.borrow_mut();
*calls += 1;
if *calls == 1 {
anyhow::bail!("simulated: base branch was deleted")
} else {
Ok("branch-tip-sha".to_string())
}
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
assert_eq!(2, *fetch_base_branch_calls.borrow());
}
#[test]
fn should_reuse_step_two_tip_for_fallback_without_refetching() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
*fetch_base_branch_calls.borrow_mut() += 1;
Ok("branch-tip-sha".to_string())
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
assert_eq!(
1,
*fetch_base_branch_calls.borrow(),
"fetch_base_branch must only be called once (by step 2); step 4 must reuse its \
result instead of fetching the base branch again"
);
}
#[test]
fn should_propagate_error_when_the_branch_tip_fallback_itself_fails() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
let mut calls = fetch_base_branch_calls.borrow_mut();
*calls += 1;
anyhow::bail!("simulated: git fetch origin main failed")
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
);
assert!(actual.is_err());
}
}
#[test]
fn should_parse_pr_view_json_into_pr_info() {
let json = r#"{"number":123,"baseRefName":"main","baseRefOid":"base789","headRefOid":"abc123def456"}"#;
let actual = parse_pr_view_json(json).expect("expected valid JSON to parse");
assert_eq!(
PrInfo {
number: 123,
base_ref_name: "main".to_string(),
base_ref_oid: "base789".to_string(),
head_ref_oid: "abc123def456".to_string(),
},
actual
);
}
#[test]
fn should_fail_to_parse_pr_view_json_when_a_required_field_is_missing() {
let json = r#"{"number":123,"baseRefName":"main"}"#;
let actual = parse_pr_view_json(json);
assert!(actual.is_err());
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git must be installed to run this test");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo_with_committed_file(dir: &std::path::Path, content: &str) {
run_git(dir, &["init", "--initial-branch=main"]);
run_git(dir, &["config", "user.email", "test@example.com"]);
run_git(dir, &["config", "user.name", "Test"]);
std::fs::create_dir_all(dir.join("src")).expect("create src dir");
std::fs::write(dir.join("src/lib.rs"), content).expect("write src/lib.rs");
run_git(dir, &["add", "src/lib.rs"]);
run_git(dir, &["commit", "-m", "initial commit"]);
}
#[test]
fn should_read_committed_content_when_working_tree_is_dirty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let committed = "fn foo(a: i32) -> i32 {\n a\n}\n";
init_repo_with_committed_file(dir.path(), committed);
std::fs::write(
dir.path().join("src/lib.rs"),
"fn foo(a: i32) -> i32 {\n a + 999\n}\n",
)
.expect("dirty the working tree");
let actual = read_git_show_file(Some(dir.path()), "HEAD", "src/lib.rs")
.expect("git show should succeed for a committed file");
assert_eq!(committed, actual);
}
#[test]
fn should_read_every_path_via_a_single_cat_file_batch_process() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").expect("write a.rs");
std::fs::write(dir.path().join("b.rs"), "fn b() {}\n").expect("write b.rs");
run_git(dir.path(), &["add", "a.rs", "b.rs"]);
run_git(dir.path(), &["commit", "-m", "initial commit"]);
let mut actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["a.rs".to_string(), "b.rs".to_string()],
)
.expect("git cat-file --batch should succeed for tracked files");
actual.sort();
assert_eq!(
vec![
("a.rs".to_string(), "fn a() {}\n".to_string()),
("b.rs".to_string(), "fn b() {}\n".to_string()),
],
actual
);
}
#[test]
fn should_read_committed_content_via_batch_when_working_tree_is_dirty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let committed = "fn foo(a: i32) -> i32 {\n a\n}\n";
init_repo_with_committed_file(dir.path(), committed);
std::fs::write(
dir.path().join("src/lib.rs"),
"fn foo(a: i32) -> i32 {\n a + 999\n}\n",
)
.expect("dirty the working tree");
let actual =
read_git_show_files_batch(Some(dir.path()), "HEAD", vec!["src/lib.rs".to_string()])
.expect("git cat-file --batch should succeed for a committed file");
assert_eq!(
vec![("src/lib.rs".to_string(), committed.to_string())],
actual
);
}
#[test]
fn should_skip_missing_paths_when_reading_via_batch() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["src/lib.rs".to_string(), "does/not/exist.rs".to_string()],
)
.expect("git cat-file --batch should succeed even with a missing path");
assert_eq!(
vec![("src/lib.rs".to_string(), "fn foo() {}\n".to_string())],
actual
);
}
#[test]
fn should_skip_non_utf8_content_when_reading_via_batch() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("text.rs"), "fn ok() {}\n").expect("write text.rs");
std::fs::write(dir.path().join("binary.dat"), [0xff_u8, 0xfe, 0x00, 0x01])
.expect("write binary.dat");
run_git(dir.path(), &["add", "text.rs", "binary.dat"]);
run_git(dir.path(), &["commit", "-m", "initial commit"]);
let mut actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["text.rs".to_string(), "binary.dat".to_string()],
)
.expect("git cat-file --batch should succeed even with binary content present");
actual.sort();
assert_eq!(
vec![("text.rs".to_string(), "fn ok() {}\n".to_string())],
actual
);
}
mod read_cat_file_batch_response_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn should_return_content_when_response_is_found() {
let mut reader = std::io::Cursor::new(b"abc123 blob 5\nhello\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs")
.expect("a well-formed found response must parse");
assert_eq!(Some("hello".to_string()), actual);
}
#[test]
fn should_return_none_when_response_is_missing() {
let mut reader = std::io::Cursor::new(b"HEAD:a.rs missing\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs")
.expect("a missing response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_response_is_ambiguous() {
let mut reader = std::io::Cursor::new(b"abc1 ambiguous\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "abc1")
.expect("an ambiguous response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_response_is_a_submodule_entry() {
let mut reader = std::io::Cursor::new(
b"3eb8e680cc28d03641be1d2af8e098e8ac6a42f8 submodule\n".to_vec(),
);
let actual = read_cat_file_batch_response(&mut reader, "HEAD:sub")
.expect("a submodule response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_found_content_is_not_valid_utf8() {
let mut reader = std::io::Cursor::new(
[
b"abc123 blob 4\n".as_slice(),
&[0xff, 0xfe, 0x00, 0x01],
b"\n",
]
.concat(),
);
let actual = read_cat_file_batch_response(&mut reader, "HEAD:binary.dat")
.expect("non-UTF-8 content must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_error_when_content_is_truncated() {
let mut reader = std::io::Cursor::new(b"abc123 blob 100\nshort\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs");
assert!(actual.is_err());
}
}
#[test]
fn should_include_stderr_in_error_when_git_cat_file_batch_exits_non_zero() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let actual = read_git_show_files_batch(Some(dir.path()), "HEAD", vec!["a.rs".to_string()]);
let error = actual.expect_err("a non-git cwd must fail rather than silently succeed");
let message = error.to_string();
assert!(
message.contains("not a git repository"),
"expected the child's stderr to be included in the error, got: {message:?}"
);
}
#[test]
fn should_return_origin_url_when_repository_has_an_origin_remote() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
run_git(
dir.path(),
&[
"remote",
"add",
"origin",
"https://github.com/octocat/hello-world.git",
],
);
let actual =
git_remote_origin_url(Some(dir.path())).expect("git remote get-url should not error");
assert_eq!(
Some("https://github.com/octocat/hello-world.git".to_string()),
actual
);
}
#[test]
fn should_return_none_when_repository_has_no_origin_remote() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let actual = git_remote_origin_url(Some(dir.path()))
.expect("missing origin remote should not error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_directory_is_not_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let actual = git_remote_origin_url(Some(dir.path()))
.expect("a non-repository directory should not error");
assert_eq!(None, actual);
}
#[test]
fn should_skip_repository_scan_when_deps_is_zero() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 0,
};
let read_file = |_: &str| -> std::io::Result<String> {
panic!("read_file must not be called when deps == 0")
};
let actual = build_resolver(&cli, "", read_file, None, Some(dir.path()))
.expect("deps == 0 must not touch the repository at all");
assert!(actual.is_none());
}
#[test]
fn should_fail_when_deps_is_one_and_cwd_has_no_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let read_file = |_: &str| -> std::io::Result<String> { Ok(String::new()) };
let actual = build_resolver(&cli, "", read_file, None, Some(dir.path()));
assert!(actual.is_err());
}
#[test]
fn should_skip_repository_scan_when_diff_is_empty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let index_path = dir.path().join(".git/index");
let mut permissions = std::fs::metadata(&index_path)
.expect("read .git/index metadata")
.permissions();
let original_mode = std::os::unix::fs::PermissionsExt::mode(&permissions);
std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o000);
std::fs::set_permissions(&index_path, permissions).expect("revoke .git/index read access");
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: Format::Md,
deps: 1,
};
let actual = run_base_pipeline(&cli, "HEAD", "HEAD", Some(dir.path()));
let mut permissions = std::fs::metadata(&index_path)
.expect("re-read .git/index metadata")
.permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, original_mode);
std::fs::set_permissions(&index_path, permissions).expect("restore .git/index permissions");
assert_eq!(
rinkaku_core::render::Report {
files: Vec::new(),
skipped: Vec::new(),
},
actual.expect("empty diff must not touch the repository-wide index scan")
);
}
mod garbage_input_note_tests {
use super::*;
use pretty_assertions::assert_eq;
use rinkaku_core::render::Report;
fn empty_report() -> Report {
Report {
files: vec![],
skipped: vec![],
}
}
fn non_empty_report() -> Report {
Report {
files: vec![rinkaku_core::render::FileReport {
path: "src/lib.rs".to_string(),
symbols: vec![],
}],
skipped: vec![],
}
}
#[test]
fn should_return_note_when_input_is_non_empty_but_report_has_no_entries() {
let actual = garbage_input_note("this is not a diff at all\n", &empty_report());
assert_eq!(
Some("note: no file changes recognized in input; expected a unified diff"),
actual
);
}
#[test]
fn should_return_none_when_input_is_empty() {
let actual = garbage_input_note("", &empty_report());
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_input_is_whitespace_only() {
let actual = garbage_input_note(" \n\n ", &empty_report());
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_has_file_entries() {
let actual = garbage_input_note("some diff text", &non_empty_report());
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_has_only_skipped_entries() {
let report = Report {
files: vec![],
skipped: vec![rinkaku_core::render::SkippedFile {
path: "assets/logo.png".to_string(),
reason: rinkaku_core::render::SkipReason::Binary,
}],
};
let actual = garbage_input_note("some diff text", &report);
assert_eq!(None, actual);
}
}
}