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::IsTerminal;
use std::io::Read;
#[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::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 number = parse_pr_arg(pr_arg)?;
let pr_info = fetch_pr_info(pr_arg.trim())?;
let head_sha = fetch_pr_head(number)?;
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 \
this 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,
);
}
let base_sha = fetch_branch_head(&pr_info.base_ref_name)?;
run_base_pipeline(&cli, &base_sha, &head_sha)?
} else if let Some(base) = &cli.base {
run_base_pipeline(&cli, base, &cli.head)?
} 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 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 run_base_pipeline(
cli: &Cli,
base: &str,
head: &str,
) -> anyhow::Result<rinkaku_core::render::Report> {
let diff_text = run_git_diff(base, head)?;
let read_file = {
let head = head.to_string();
move |path: &str| read_git_show_file(None, &head, path)
};
let resolver = build_resolver(cli, &diff_text, &read_file, Some(head), None)?;
Ok(analyze_diff(
&diff_text,
read_file,
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
)?)
}
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)?;
let files = paths.into_iter().filter_map(|path| {
let content = match head {
Some(head) => read_git_show_file(cwd, head, &path),
None => read_working_tree_file(&path),
};
content.ok().map(|content| (path, content))
});
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) -> anyhow::Result<String> {
let range = format!("{base}...{head}");
let output = std::process::Command::new("git")
.args(["diff", &range])
.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 = "headRefOid")]
head_ref_oid: String,
}
fn parse_pr_arg(value: &str) -> anyhow::Result<u64> {
let candidate = 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, ..] => *number,
_ => anyhow::bail!(
"--pr URL must look like https://github.com/<owner>/<repo>/pull/<number>, \
got: {value}"
),
}
}
None => value.trim(),
};
let number: u64 = candidate.parse().map_err(|_| {
anyhow::anyhow!("--pr must be a PR number or a GitHub PR URL, got: {value}")
})?;
if number == 0 {
anyhow::bail!("--pr must be a positive PR number, got: {value}");
}
Ok(number)
}
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,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) -> anyhow::Result<String> {
run_git_fetch(&format!("refs/pull/{number}/head"))
}
fn fetch_branch_head(name: &str) -> anyhow::Result<String> {
run_git_fetch(name)
}
fn run_git_fetch(refspec: &str) -> anyhow::Result<String> {
let fetch_output = std::process::Command::new("git")
.args(["fetch", "origin", refspec])
.output()?;
if !fetch_output.status.success() {
anyhow::bail!(
"git fetch origin {refspec} failed: {}",
String::from_utf8_lossy(&fetch_output.stderr)
);
}
let rev_parse_output = std::process::Command::new("git")
.args(["rev-parse", "FETCH_HEAD"])
.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 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))
}
#[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", 76)]
#[case::should_parse_number_with_surrounding_whitespace(" 76 ", 76)]
#[case::should_parse_pull_url("https://github.com/octocat/hello-world/pull/123", 123)]
#[case::should_parse_pull_url_with_trailing_slash(
"https://github.com/octocat/hello-world/pull/123/",
123
)]
#[case::should_parse_pull_url_with_extra_path_segment(
"https://github.com/octocat/hello-world/pull/123/files",
123
)]
fn should_parse_pr_arg_when_input_is_valid(#[case] input: &str, #[case] expected: u64) {
let actual = parse_pr_arg(input).expect("expected a valid PR number");
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}");
}
#[test]
fn should_parse_pr_view_json_into_pr_info() {
let json = r#"{"number":123,"baseRefName":"main","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(),
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_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());
}
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);
}
}
}