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, conflicts_with = "tui")]
format: Option<Format>,
#[arg(long, default_value_t = false)]
tui: bool,
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(0..=1))]
deps: u8,
#[arg(long, default_value_t = false)]
exclude_tests: bool,
#[arg(long, default_value_t = false)]
include_generated: bool,
#[arg(long)]
entry: Option<String>,
}
#[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,
Mermaid,
}
impl From<Format> for OutputFormat {
fn from(format: Format) -> Self {
match format {
Format::Md => OutputFormat::Markdown,
Format::Json => OutputFormat::Json,
Format::Mermaid => OutputFormat::Mermaid,
}
}
}
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 mut resolved_workdir: Option<std::path::PathBuf> = None;
let (report, diff_text) = if let Some(pr_arg) = &cli.pr {
let parsed = parse_pr_arg(pr_arg)?;
let number = parsed.number();
let workdir = resolve_pr_workdir(&parsed)?;
resolved_workdir = workdir.clone();
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 if std::io::stdin().is_terminal() {
log::info!("no diff input and stdin is a terminal; building a whole-repo outline");
let paths = list_repo_files_for_outline(None)?;
let generated_paths = if cli.include_generated {
std::collections::HashSet::new()
} else {
check_generated_paths_batch(None, &paths)
};
let report = rinkaku_core::pipeline::analyze_repo(
&paths,
read_working_tree_file,
!cli.exclude_tests,
&generated_paths,
cli.include_generated,
);
if let Some(note) = repo_outline_empty_note(&report) {
eprintln!("{note}");
}
(report, String::new())
} 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)?;
let changed_paths = changed_paths(&diff_text)?;
let generated_paths = resolve_generated_paths(&cli, &changed_paths, None);
log::info!("analyzing diff");
let report = analyze_diff(
&diff_text,
read_working_tree_file,
None,
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
!cli.exclude_tests,
&generated_paths,
cli.include_generated,
)?;
if let Some(note) = garbage_input_note(&diff_text, &report) {
eprintln!("{note}");
}
(report, diff_text)
};
let report = if let Some(entry) = &cli.entry {
let pivoted = apply_entry_pivot(report, entry);
if let Some(note) = entry_pivot_empty_note(&pivoted, entry) {
eprintln!("{note}");
}
pivoted
} else {
report
};
let stdout_is_tty = std::io::stdout().is_terminal();
match resolve_display_mode(cli.tui, cli.format, stdout_is_tty) {
DisplayMode::Tui => {
let repo_root = resolve_repo_root(resolved_workdir.as_deref());
rinkaku_tui::run(&report, &diff_text, cli.entry.as_deref(), &repo_root)?
}
DisplayMode::Output(format) => {
let output = render(&report, format.into())?;
print!("{output}");
}
}
Ok(())
}
fn apply_entry_pivot(
report: rinkaku_core::render::Report,
path: &str,
) -> rinkaku_core::render::Report {
let graph = rinkaku_core::graph::pivot_graph(&report.graph, path);
rinkaku_core::render::Report { graph, ..report }
}
fn entry_pivot_empty_note(report: &rinkaku_core::render::Report, path: &str) -> Option<String> {
if report.graph.nodes.is_empty() {
return None;
}
if report.graph.roots.is_empty() {
Some(format!("note: no symbols under {path}"))
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DisplayMode {
Tui,
Output(Format),
}
fn resolve_display_mode(tui: bool, format: Option<Format>, stdout_is_tty: bool) -> DisplayMode {
if tui {
return DisplayMode::Tui;
}
if let Some(format) = format {
return DisplayMode::Output(format);
}
if stdout_is_tty {
DisplayMode::Tui
} else {
DisplayMode::Output(Format::Md)
}
}
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, String)> {
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 {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: Vec::new(),
skipped: Vec::new(),
graph: rinkaku_core::graph::SymbolGraph {
nodes: Vec::new(),
edges: Vec::new(),
roots: Vec::new(),
},
tests: Vec::new(),
hotspots: Vec::new(),
removed: Vec::new(),
},
diff_text,
));
}
let read_file = {
let head = head.to_string();
move |path: &str| read_git_show_file(cwd, &head, path)
};
let read_base_file = {
let base = base.to_string();
move |path: &str| read_git_show_file(cwd, &base, path)
};
let resolver = build_resolver(cli, &diff_text, &read_file, Some(head), cwd)?;
let changed_paths = changed_paths(&diff_text)?;
let generated_paths = resolve_generated_paths(cli, &changed_paths, cwd);
log::info!("analyzing diff");
let report = analyze_diff(
&diff_text,
read_file,
Some(&read_base_file),
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
!cli.exclude_tests,
&generated_paths,
cli.include_generated,
)?;
if let Some(note) = garbage_input_note(&diff_text, &report) {
eprintln!("{note}");
}
Ok((report, diff_text))
}
fn changed_paths(diff_text: &str) -> anyhow::Result<Vec<String>> {
Ok(rinkaku_core::diff::parse_unified_diff(diff_text)?
.into_iter()
.map(|changed_file| changed_file.path)
.collect())
}
fn resolve_generated_paths(
cli: &Cli,
changed_paths: &[String],
cwd: Option<&std::path::Path>,
) -> std::collections::HashSet<String> {
if cli.include_generated {
return std::collections::HashSet::new();
}
check_generated_paths(cwd, changed_paths)
}
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() || !report.tests.is_empty() {
return None;
}
Some("note: no file changes recognized in input; expected a unified diff")
}
fn repo_outline_empty_note(report: &rinkaku_core::render::Report) -> Option<&'static str> {
if !report.files.is_empty() || !report.removed.is_empty() {
return None;
}
Some("note: no supported source files found in the repository")
}
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 generated_paths = if cli.include_generated {
std::collections::HashSet::new()
} else {
check_generated_paths_batch(cwd, &paths)
};
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,
!cli.exclude_tests,
&generated_paths,
cli.include_generated,
)))
}
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 list_repo_files_for_outline(cwd: Option<&std::path::Path>) -> anyhow::Result<Vec<String>> {
use anyhow::Context;
list_git_files(cwd).context(
"run rinkaku inside a git repository, or pipe a diff (e.g. `gh pr diff 123 | rinkaku`) \
or pass --base <ref>",
)
}
fn check_generated_paths(
cwd: Option<&std::path::Path>,
paths: &[String],
) -> std::collections::HashSet<String> {
if paths.is_empty() {
return std::collections::HashSet::new();
}
let mut command = std::process::Command::new("git");
command
.args(["check-attr", "-z", "diff", "linguist-generated", "--"])
.args(paths);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let Ok(output) = command.output() else {
return std::collections::HashSet::new();
};
if !output.status.success() {
return std::collections::HashSet::new();
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return std::collections::HashSet::new();
};
parse_generated_paths(&stdout)
}
fn check_generated_paths_batch(
cwd: Option<&std::path::Path>,
paths: &[String],
) -> std::collections::HashSet<String> {
if paths.is_empty() {
return std::collections::HashSet::new();
}
let mut command = std::process::Command::new("git");
command
.args(["check-attr", "--stdin", "-z", "diff", "linguist-generated"])
.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 Ok(mut child) = command.spawn() else {
return std::collections::HashSet::new();
};
let Some(mut stdin) = child.stdin.take() else {
return std::collections::HashSet::new();
};
let Some(mut stdout) = child.stdout.take() else {
return std::collections::HashSet::new();
};
let stderr = child.stderr.take();
let stderr_reader = stderr.map(|mut stderr| {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut stderr, &mut buf);
buf
})
});
let paths_owned: Vec<String> = paths.to_vec();
let writer = std::thread::spawn(move || -> std::io::Result<()> {
for path in &paths_owned {
stdin.write_all(path.as_bytes())?;
stdin.write_all(b"\0")?;
}
Ok(())
});
let mut stdout_bytes = Vec::new();
if std::io::Read::read_to_end(&mut stdout, &mut stdout_bytes).is_err() {
return std::collections::HashSet::new();
}
let Ok(Ok(())) = writer.join() else {
return std::collections::HashSet::new();
};
let status = child.wait();
if let Some(reader) = stderr_reader {
let _ = reader.join();
}
let Ok(status) = status else {
return std::collections::HashSet::new();
};
if !status.success() {
return std::collections::HashSet::new();
}
let Ok(stdout_text) = String::from_utf8(stdout_bytes) else {
return std::collections::HashSet::new();
};
parse_generated_paths(&stdout_text)
}
fn parse_generated_paths(output: &str) -> std::collections::HashSet<String> {
let fields: Vec<&str> = output
.split('\0')
.filter(|field| !field.is_empty())
.collect();
let mut generated = std::collections::HashSet::new();
for triple in fields.chunks_exact(3) {
let [path, attribute, value] = triple else {
continue;
};
let is_generated = (*attribute == "diff" && *value == "unset")
|| (*attribute == "linguist-generated" && matches!(*value, "set" | "true"));
if is_generated {
generated.insert((*path).to_string());
}
}
generated
}
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 resolve_repo_root(cwd: Option<&std::path::Path>) -> std::path::PathBuf {
let mut command = std::process::Command::new("git");
command.args(["rev-parse", "--show-toplevel"]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let toplevel = command
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|stdout| std::path::PathBuf::from(stdout.trim()));
toplevel.unwrap_or_else(|| match cwd {
Some(cwd) => cwd.to_path_buf(),
None => std::env::current_dir().unwrap_or_default(),
})
}
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 pump_result = pump_cat_file_batch_requests(&mut stdin, &mut reader, head, paths);
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());
combine_cat_file_batch_result(pump_result, &status, &stderr_output)
}
fn pump_cat_file_batch_requests(
stdin: &mut std::process::ChildStdin,
reader: &mut impl BufRead,
head: &str,
paths: Vec<String>,
) -> anyhow::Result<Vec<(String, String)>> {
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(reader, &object)? {
Some(content) => files.push((path, content)),
None => continue,
}
}
Ok(files)
}
fn combine_cat_file_batch_result(
pump_result: anyhow::Result<Vec<(String, String)>>,
status: &std::process::ExitStatus,
stderr_output: &[u8],
) -> anyhow::Result<Vec<(String, String)>> {
match (pump_result, status.success()) {
(Ok(files), true) => Ok(files),
(Ok(_), false) => Err(anyhow::anyhow!(
"git cat-file --batch exited with {status}: {}",
String::from_utf8_lossy(stderr_output)
)),
(Err(pump_error), false) => Err(anyhow::anyhow!(
"git cat-file --batch exited with {status}: {} (pump error: {pump_error})",
String::from_utf8_lossy(stderr_output)
)),
(Err(pump_error), true) => Err(pump_error),
}
}
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;
use std::collections::HashSet;
#[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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
let actual = Cli::parse_from(["rinkaku"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_tui_when_tui_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: true,
};
let actual = Cli::parse_from(["rinkaku", "--tui"]);
assert_eq!(expected, actual);
}
#[test]
fn should_reject_tui_and_format_given_together() {
let actual = Cli::try_parse_from(["rinkaku", "--tui", "--format", "json"]);
assert!(actual.is_err());
}
#[test]
fn should_reject_format_and_tui_given_together_regardless_of_argument_order() {
let actual = Cli::try_parse_from(["rinkaku", "--format", "json", "--tui"]);
assert!(actual.is_err());
}
#[rstest]
#[case::should_choose_tui_when_tui_flag_is_set_and_stdout_is_a_terminal(
true,
None,
true,
DisplayMode::Tui
)]
#[case::should_choose_tui_when_tui_flag_is_set_and_stdout_is_not_a_terminal(
true,
None,
false,
DisplayMode::Tui
)]
#[case::should_choose_explicit_format_over_terminal_stdout(
false,
Some(Format::Json),
true,
DisplayMode::Output(Format::Json)
)]
#[case::should_choose_explicit_format_over_non_terminal_stdout(
false,
Some(Format::Md),
false,
DisplayMode::Output(Format::Md)
)]
#[case::should_default_to_tui_when_neither_flag_is_set_and_stdout_is_a_terminal(
false,
None,
true,
DisplayMode::Tui
)]
#[case::should_default_to_markdown_when_neither_flag_is_set_and_stdout_is_not_a_terminal(
false,
None,
false,
DisplayMode::Output(Format::Md)
)]
fn resolve_display_mode_cases(
#[case] tui: bool,
#[case] format: Option<Format>,
#[case] stdout_is_tty: bool,
#[case] expected: DisplayMode,
) {
let actual = resolve_display_mode(tui, format, stdout_is_tty);
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: Some(Format::Json),
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 0,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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_exclude_tests_when_exclude_tests_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: true,
include_generated: false,
entry: None,
tui: false,
};
let actual = Cli::parse_from(["rinkaku", "--exclude-tests"]);
assert_eq!(expected, actual);
}
#[test]
fn should_default_to_including_tests_when_no_flag_given() {
let actual = Cli::parse_from(["rinkaku"]);
assert_eq!(false, actual.exclude_tests);
}
#[test]
fn should_reject_the_removed_include_tests_flag() {
let actual = Cli::try_parse_from(["rinkaku", "--include-tests"]);
assert!(actual.is_err());
}
#[test]
fn should_set_include_generated_when_include_generated_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: false,
include_generated: true,
entry: None,
tui: false,
};
let actual = Cli::parse_from(["rinkaku", "--include-generated"]);
assert_eq!(expected, actual);
}
#[test]
fn should_set_entry_when_entry_flag_given() {
let expected = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: Some("src/api".to_string()),
tui: false,
};
let actual = Cli::parse_from(["rinkaku", "--entry", "src/api"]);
assert_eq!(expected, actual);
}
#[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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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);
}
#[test]
fn should_mark_path_generated_when_diff_attribute_is_unset() {
let output = "Cargo.lock\0diff\0unset\0Cargo.lock\0linguist-generated\0unspecified\0";
let expected: HashSet<String> = ["Cargo.lock".to_string()].into_iter().collect();
let actual = parse_generated_paths(output);
assert_eq!(expected, actual);
}
#[test]
fn should_mark_path_generated_when_linguist_generated_attribute_value_is_true() {
let output = "gen/foo.go\0diff\0unspecified\0gen/foo.go\0linguist-generated\0true\0";
let expected: HashSet<String> = ["gen/foo.go".to_string()].into_iter().collect();
let actual = parse_generated_paths(output);
assert_eq!(expected, actual);
}
#[test]
fn should_mark_path_generated_when_linguist_generated_attribute_is_bare_set() {
let output = "gen/foo.go\0diff\0unspecified\0gen/foo.go\0linguist-generated\0set\0";
let expected: HashSet<String> = ["gen/foo.go".to_string()].into_iter().collect();
let actual = parse_generated_paths(output);
assert_eq!(expected, actual);
}
#[test]
fn should_not_mark_path_generated_when_both_attributes_are_unspecified() {
let output = "normal.rs\0diff\0unspecified\0normal.rs\0linguist-generated\0unspecified\0";
let expected: HashSet<String> = HashSet::new();
let actual = parse_generated_paths(output);
assert_eq!(expected, actual);
}
#[test]
fn should_mark_only_matching_path_when_multiple_paths_are_queried() {
let output = "\
Cargo.lock\0diff\0unset\0Cargo.lock\0linguist-generated\0unspecified\0normal.rs\0diff\0unspecified\0normal.rs\0linguist-generated\0unspecified\0";
let expected: HashSet<String> = ["Cargo.lock".to_string()].into_iter().collect();
let actual = parse_generated_paths(output);
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_output_is_empty() {
let expected: HashSet<String> = HashSet::new();
let actual = parse_generated_paths("");
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_report_paths_marked_generated_in_gitattributes() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
std::fs::write(
dir.path().join(".gitattributes"),
"Cargo.lock -diff\ngen/*.go linguist-generated=true\n",
)
.expect("write .gitattributes");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
std::fs::write(dir.path().join("normal.rs"), "").expect("write normal.rs");
std::fs::create_dir_all(dir.path().join("gen")).expect("create gen dir");
std::fs::write(dir.path().join("gen/foo.go"), "").expect("write gen/foo.go");
let paths = vec![
"Cargo.lock".to_string(),
"normal.rs".to_string(),
"gen/foo.go".to_string(),
];
let actual = check_generated_paths(Some(dir.path()), &paths);
let expected: HashSet<String> = ["Cargo.lock".to_string(), "gen/foo.go".to_string()]
.into_iter()
.collect();
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_cwd_is_not_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
let actual = check_generated_paths(Some(dir.path()), &["Cargo.lock".to_string()]);
let expected: HashSet<String> = HashSet::new();
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_paths_is_empty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
let actual = check_generated_paths(Some(dir.path()), &[]);
let expected: HashSet<String> = HashSet::new();
assert_eq!(expected, actual);
}
#[test]
fn should_report_paths_marked_generated_via_stdin_batch() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
std::fs::write(
dir.path().join(".gitattributes"),
"Cargo.lock -diff\ngen/*.go linguist-generated=true\n",
)
.expect("write .gitattributes");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
std::fs::write(dir.path().join("normal.rs"), "").expect("write normal.rs");
std::fs::create_dir_all(dir.path().join("gen")).expect("create gen dir");
std::fs::write(dir.path().join("gen/foo.go"), "").expect("write gen/foo.go");
let paths = vec![
"Cargo.lock".to_string(),
"normal.rs".to_string(),
"gen/foo.go".to_string(),
];
let actual = check_generated_paths_batch(Some(dir.path()), &paths);
let expected: HashSet<String> = ["Cargo.lock".to_string(), "gen/foo.go".to_string()]
.into_iter()
.collect();
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_batch_cwd_is_not_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
let actual = check_generated_paths_batch(Some(dir.path()), &["Cargo.lock".to_string()]);
let expected: HashSet<String> = HashSet::new();
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_batch_paths_is_empty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
let actual = check_generated_paths_batch(Some(dir.path()), &[]);
let expected: HashSet<String> = HashSet::new();
assert_eq!(expected, actual);
}
#[test]
fn should_handle_many_paths_via_stdin_without_hitting_arg_limits() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
std::fs::write(
dir.path().join(".gitattributes"),
"gen/*.go linguist-generated=true\n",
)
.expect("write .gitattributes");
std::fs::create_dir_all(dir.path().join("gen")).expect("create gen dir");
let mut paths = Vec::new();
for i in 0..5000 {
let path = format!("gen/file{i}.go");
std::fs::write(dir.path().join(&path), "").expect("write generated file");
paths.push(path);
}
let actual = check_generated_paths_batch(Some(dir.path()), &paths);
assert_eq!(paths.len(), actual.len());
}
#[test]
fn should_resolve_generated_paths_from_already_parsed_changed_paths() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
std::fs::write(dir.path().join(".gitattributes"), "Cargo.lock -diff\n")
.expect("write .gitattributes");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
let changed_paths = vec!["Cargo.lock".to_string()];
let actual = resolve_generated_paths(&cli, &changed_paths, Some(dir.path()));
let expected: HashSet<String> = ["Cargo.lock".to_string()].into_iter().collect();
assert_eq!(expected, actual);
}
#[test]
fn should_return_empty_set_when_include_generated_is_true() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
std::fs::write(dir.path().join(".gitattributes"), "Cargo.lock -diff\n")
.expect("write .gitattributes");
std::fs::write(dir.path().join("Cargo.lock"), "").expect("write Cargo.lock");
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 1,
exclude_tests: false,
include_generated: true,
entry: None,
tui: false,
};
let changed_paths = vec!["Cargo.lock".to_string()];
let actual = resolve_generated_paths(&cli, &changed_paths, Some(dir.path()));
let expected: HashSet<String> = HashSet::new();
assert_eq!(expected, 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());
}
}
mod combine_cat_file_batch_result_tests {
use super::*;
use pretty_assertions::assert_eq;
use std::os::unix::process::ExitStatusExt;
fn exit_status(code: i32) -> std::process::ExitStatus {
std::process::ExitStatus::from_raw(code)
}
#[test]
fn should_return_files_when_pump_succeeds_and_status_succeeds() {
let pump_result = Ok(vec![("a.rs".to_string(), "fn a() {}\n".to_string())]);
let actual = combine_cat_file_batch_result(pump_result, &exit_status(0), b"")
.expect("a successful pump and exit status must not error");
assert_eq!(
vec![("a.rs".to_string(), "fn a() {}\n".to_string())],
actual
);
}
#[test]
fn should_return_stderr_error_when_pump_succeeds_and_status_fails() {
let pump_result = Ok(vec![]);
let actual = combine_cat_file_batch_result(
pump_result,
&exit_status(1 << 8),
b"fatal: not a git repository (or any of the parent directories): .git\n",
);
let message = actual
.expect_err("a failing exit status must be an error")
.to_string();
assert!(
message.contains(
"fatal: not a git repository (or any of the parent directories): .git"
),
"expected the stderr text in the error message, got: {message:?}"
);
}
#[test]
fn should_prefer_stderr_error_over_pump_error_when_both_fail() {
let pump_result: anyhow::Result<Vec<(String, String)>> = Err(anyhow::anyhow!(
"failed to write to git cat-file --batch: Broken pipe (os error 32)"
));
let actual = combine_cat_file_batch_result(
pump_result,
&exit_status(128 << 8),
b"fatal: not a git repository (or any of the parent directories): .git\n",
);
let message = actual
.expect_err("a failing pump and a failing exit status must be an error")
.to_string();
assert!(
message.starts_with("git cat-file --batch exited with"),
"expected the stderr-derived message to be primary, got: {message:?}"
);
assert!(
message.contains(
"fatal: not a git repository (or any of the parent directories): .git"
),
"expected the stderr text in the error message, got: {message:?}"
);
assert!(
message.contains("Broken pipe"),
"expected the pump error to be folded in as extra detail, got: {message:?}"
);
}
#[test]
fn should_return_pump_error_unchanged_when_pump_fails_and_status_succeeds() {
let pump_result: anyhow::Result<Vec<(String, String)>> = Err(anyhow::anyhow!(
"failed to read git cat-file --batch header: unexpected EOF"
));
let actual = combine_cat_file_batch_result(pump_result, &exit_status(0), b"");
let message = actual
.expect_err("a failing pump must be an error even if the exit status succeeded")
.to_string();
assert_eq!(
"failed to read git cat-file --batch header: unexpected EOF",
message
);
}
}
#[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_resolve_repository_root_when_cwd_is_a_subdirectory() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let subdir = dir.path().join("src");
let actual = resolve_repo_root(Some(&subdir));
let expected = dir.path().canonicalize().expect("canonicalize expected");
let actual = actual.canonicalize().expect("canonicalize actual");
assert_eq!(expected, actual);
}
#[test]
fn should_fall_back_to_cwd_when_directory_is_not_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let actual = resolve_repo_root(Some(dir.path()));
assert_eq!(dir.path(), actual);
}
#[test]
fn should_resolve_pr_workdir_root_not_process_cwd_repo_when_both_are_git_repos() {
let process_repo = tempfile::TempDir::new().expect("create process repo tempdir");
init_repo_with_committed_file(process_repo.path(), "fn process_repo_marker() {}\n");
let pr_repo = tempfile::TempDir::new().expect("create pr repo tempdir");
init_repo_with_committed_file(pr_repo.path(), "fn pr_repo_marker() {}\n");
let actual = resolve_repo_root(Some(pr_repo.path()));
let expected = pr_repo
.path()
.canonicalize()
.expect("canonicalize expected");
let actual = actual.canonicalize().expect("canonicalize actual");
assert_eq!(expected, actual);
assert_ne!(
process_repo
.path()
.canonicalize()
.expect("canonicalize process_repo"),
actual,
"must not resolve the unrelated process-cwd repository"
);
}
#[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: None,
deps: 0,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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: None,
deps: 1,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
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");
let (actual_report, _actual_diff_text) =
actual.expect("empty diff must not touch the repository-wide index scan");
assert_eq!(
rinkaku_core::render::Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: Vec::new(),
skipped: Vec::new(),
graph: rinkaku_core::graph::SymbolGraph {
nodes: Vec::new(),
edges: Vec::new(),
roots: Vec::new(),
},
tests: Vec::new(),
hotspots: Vec::new(),
removed: Vec::new(),
},
actual_report
);
}
#[test]
fn should_produce_test_only_report_without_garbage_input_shape_when_diff_touches_only_a_test_under_exclude_tests()
{
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(
dir.path(),
"\
#[test]
fn should_add_two_numbers() {
assert_eq!(1, 1 + 0);
}
",
);
std::fs::write(
dir.path().join("src/lib.rs"),
"\
#[test]
fn should_add_two_numbers() {
assert_eq!(2, 1 + 1);
}
",
)
.expect("edit src/lib.rs");
run_git(dir.path(), &["add", "src/lib.rs"]);
run_git(dir.path(), &["commit", "-m", "fix test assertion"]);
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 0,
exclude_tests: true,
include_generated: false,
entry: None,
tui: false,
};
let (actual, _diff_text) = run_base_pipeline(&cli, "HEAD~1", "HEAD", Some(dir.path()))
.expect("run_base_pipeline should succeed for a test-only diff");
let expected_files: Vec<rinkaku_core::render::FileReport> = Vec::new();
let expected_skipped: Vec<rinkaku_core::render::SkippedFile> = Vec::new();
assert_eq!(expected_files, actual.files);
assert_eq!(expected_skipped, actual.skipped);
assert_eq!(1, actual.tests.len());
assert_eq!(
None,
garbage_input_note("dummy non-empty diff text", &actual)
);
}
#[test]
fn should_include_test_symbol_in_files_when_diff_touches_only_a_test_under_default() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(
dir.path(),
"\
#[test]
fn should_add_two_numbers() {
assert_eq!(1, 1 + 0);
}
",
);
std::fs::write(
dir.path().join("src/lib.rs"),
"\
#[test]
fn should_add_two_numbers() {
assert_eq!(2, 1 + 1);
}
",
)
.expect("edit src/lib.rs");
run_git(dir.path(), &["add", "src/lib.rs"]);
run_git(dir.path(), &["commit", "-m", "fix test assertion"]);
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 0,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
let (actual, _diff_text) = run_base_pipeline(&cli, "HEAD~1", "HEAD", Some(dir.path()))
.expect("run_base_pipeline should succeed for a test-only diff");
let expected_tests: Vec<rinkaku_core::render::TestFileSummary> = Vec::new();
assert_eq!(expected_tests, actual.tests);
assert_eq!(1, actual.files.len());
assert_eq!(1, actual.files[0].symbols.len());
assert_eq!(true, actual.files[0].symbols[0].is_test);
}
#[test]
fn should_classify_symbol_as_signature_changed_via_real_base_commit() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo(a: i32) -> i32 {\n a\n}\n");
std::fs::write(
dir.path().join("src/lib.rs"),
"fn foo(a: i32, b: i32) -> i32 {\n a\n}\n",
)
.expect("edit src/lib.rs");
run_git(dir.path(), &["add", "src/lib.rs"]);
run_git(dir.path(), &["commit", "-m", "widen foo's signature"]);
let cli = Cli {
command: None,
base: None,
head: "HEAD".to_string(),
pr: None,
format: None,
deps: 0,
exclude_tests: false,
include_generated: false,
entry: None,
tui: false,
};
let (actual, _diff_text) = run_base_pipeline(&cli, "HEAD~1", "HEAD", Some(dir.path()))
.expect("run_base_pipeline should succeed");
let symbol = &actual.files[0].symbols[0];
assert_eq!(
Some(rinkaku_core::extract::Classification::SignatureChanged),
symbol.classification
);
assert_eq!(
Some("fn foo(a: i32) -> i32".to_string()),
symbol.previous_signature
);
}
mod garbage_input_note_tests {
use super::*;
use pretty_assertions::assert_eq;
use rinkaku_core::render::Report;
fn empty_graph() -> rinkaku_core::graph::SymbolGraph {
rinkaku_core::graph::SymbolGraph {
nodes: vec![],
edges: vec![],
roots: vec![],
}
}
fn empty_report() -> Report {
Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![],
skipped: vec![],
graph: empty_graph(),
tests: vec![],
hotspots: vec![],
removed: vec![],
}
}
fn non_empty_report() -> Report {
Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![rinkaku_core::render::FileReport {
path: "src/lib.rs".to_string(),
symbols: vec![],
}],
skipped: vec![],
graph: empty_graph(),
tests: vec![],
hotspots: vec![],
removed: 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 {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![],
skipped: vec![rinkaku_core::render::SkippedFile {
path: "assets/logo.png".to_string(),
reason: rinkaku_core::render::SkipReason::Binary,
}],
graph: empty_graph(),
tests: vec![],
hotspots: vec![],
removed: vec![],
};
let actual = garbage_input_note("some diff text", &report);
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_has_only_test_summary_entries() {
let report = Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![],
skipped: vec![],
graph: empty_graph(),
tests: vec![rinkaku_core::render::TestFileSummary {
path: "src/lib.rs".to_string(),
symbol_count: 1,
}],
hotspots: vec![],
removed: vec![],
};
let actual = garbage_input_note("some diff text", &report);
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_has_only_generated_skip_entries() {
let report = Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![],
skipped: vec![
rinkaku_core::render::SkippedFile {
path: "Cargo.lock".to_string(),
reason: rinkaku_core::render::SkipReason::Generated,
},
rinkaku_core::render::SkippedFile {
path: "vendor/generated.go".to_string(),
reason: rinkaku_core::render::SkipReason::Generated,
},
],
graph: empty_graph(),
tests: vec![],
hotspots: vec![],
removed: vec![],
};
let actual = garbage_input_note("some diff text", &report);
assert_eq!(None, actual);
}
}
mod apply_entry_pivot_tests {
use super::*;
use pretty_assertions::assert_eq;
use rinkaku_core::diff::LineRange;
use rinkaku_core::extract::{ExtractedSymbol, SymbolKind};
use rinkaku_core::render::{FileReport, Report};
fn symbol(name: &str, referenced_names: Vec<&str>) -> ExtractedSymbol {
ExtractedSymbol {
id: String::new(),
name: name.to_string(),
kind: SymbolKind::Function,
signature: format!("fn {name}()"),
range: LineRange { start: 1, end: 1 },
container: None,
referenced_names: referenced_names.into_iter().map(str::to_string).collect(),
dependencies: vec![],
omitted_dependency_matches: 0,
is_test: false,
classification: None,
previous_signature: None,
}
}
fn report_with_api_and_util() -> Report {
let files = vec![
FileReport {
path: "src/api/handler.rs".to_string(),
symbols: vec![symbol("api", vec!["helper"])],
},
FileReport {
path: "src/util.rs".to_string(),
symbols: vec![symbol("helper", vec![])],
},
];
let graph = rinkaku_core::graph::build_graph(&files);
Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files,
skipped: vec![],
graph,
tests: vec![],
hotspots: vec![],
removed: vec![],
}
}
#[test]
fn should_re_root_graph_at_prefix_while_leaving_other_fields_untouched() {
let report = report_with_api_and_util();
let actual = apply_entry_pivot(report.clone(), "src/api");
let expected = Report {
graph: rinkaku_core::graph::pivot_graph(&report.graph, "src/api"),
..report
};
assert_eq!(expected, actual);
}
#[test]
fn should_return_no_symbols_under_path_note_when_prefix_matches_nothing() {
let report = apply_entry_pivot(report_with_api_and_util(), "no/such/path");
let actual = entry_pivot_empty_note(&report, "no/such/path");
assert_eq!(
Some("note: no symbols under no/such/path".to_string()),
actual
);
}
#[test]
fn should_return_none_when_prefix_matches_at_least_one_symbol() {
let report = apply_entry_pivot(report_with_api_and_util(), "src/api");
let actual = entry_pivot_empty_note(&report, "src/api");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_graph_has_no_nodes_at_all() {
let report = Report {
origin: rinkaku_core::render::ReportOrigin::Diff,
files: vec![],
skipped: vec![],
graph: rinkaku_core::graph::SymbolGraph {
nodes: vec![],
edges: vec![],
roots: vec![],
},
tests: vec![],
hotspots: vec![],
removed: vec![],
};
let actual = entry_pivot_empty_note(&report, "src/api");
assert_eq!(None, actual);
}
}
mod repo_outline_empty_note_tests {
use super::*;
use pretty_assertions::assert_eq;
use rinkaku_core::render::Report;
fn empty_graph() -> rinkaku_core::graph::SymbolGraph {
rinkaku_core::graph::SymbolGraph {
nodes: vec![],
edges: vec![],
roots: vec![],
}
}
fn empty_report() -> Report {
Report {
origin: rinkaku_core::render::ReportOrigin::RepoOutline,
files: vec![],
skipped: vec![],
graph: empty_graph(),
tests: vec![],
hotspots: vec![],
removed: vec![],
}
}
#[test]
fn should_return_note_when_report_has_no_files_and_no_removed() {
let actual = repo_outline_empty_note(&empty_report());
assert_eq!(
Some("note: no supported source files found in the repository"),
actual
);
}
#[test]
fn should_return_none_when_report_has_file_entries() {
let report = Report {
files: vec![rinkaku_core::render::FileReport {
path: "src/lib.rs".to_string(),
symbols: vec![],
}],
..empty_report()
};
let actual = repo_outline_empty_note(&report);
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_report_has_removed_entries() {
let report = Report {
removed: vec![rinkaku_core::extract::RemovedSymbol {
name: "old_helper".to_string(),
kind: rinkaku_core::extract::SymbolKind::Function,
path: "src/lib.rs".to_string(),
signature: "fn old_helper()".to_string(),
}],
..empty_report()
};
let actual = repo_outline_empty_note(&report);
assert_eq!(None, actual);
}
}
mod list_repo_files_for_outline_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn should_include_guidance_in_error_when_cwd_is_not_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let actual = list_repo_files_for_outline(Some(dir.path()));
let error = actual.expect_err("a non-git directory must fail");
let message = format!("{error:#}");
assert!(
message.contains("run rinkaku inside a git repository"),
"error message did not contain the expected guidance: {message}"
);
}
#[test]
fn should_return_tracked_paths_when_cwd_is_a_git_repository() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let actual = list_repo_files_for_outline(Some(dir.path()))
.expect("a git repository must succeed");
assert_eq!(vec!["src/lib.rs".to_string()], actual);
}
}
}