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)]
base: Option<String>,
#[arg(long, default_value = "HEAD")]
head: 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 = match &cli.base {
Some(base) => {
let diff_text = run_git_diff(base, &cli.head)?;
let head = cli.head.clone();
let read_file = {
let head = head.clone();
move |path: &str| read_git_show_file(None, &head, path)
};
let resolver = build_resolver(&cli, &diff_text, &read_file, Some(&head), None)?;
analyze_diff(
&diff_text,
read_file,
resolver
.as_ref()
.map(|r| r as &dyn rinkaku_core::deps::Resolver),
)?
}
None => {
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 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)?)
}
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;
#[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(),
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(),
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(),
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(),
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(),
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(),
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(),
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(),
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();
}
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(),
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(),
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);
}
}
}