mod cli;
mod display;
mod generated_paths;
mod git;
mod github;
mod notes;
mod pipeline;
mod progress;
mod self_update;
mod spinner;
mod splash_progress;
#[cfg(test)]
mod test_util;
use clap::Parser;
use cli::{Cli, Command};
use display::{DisplayMode, resolve_display_mode};
use generated_paths::check_generated_paths_batch;
use git::commands::{list_repo_files_for_outline, resolve_repo_root};
use git::file_read::read_working_tree_file;
use github::base_sha::{
fetch_branch_head, fetch_oid, fetch_pr_head, object_exists_locally, resolve_pr_base_sha,
};
use github::pr_arg::parse_pr_arg;
use github::pr_info::fetch_pr_info;
use github::workdir::resolve_pr_workdir;
use notes::{
apply_entry_pivot, entry_pivot_empty_note, garbage_input_note, repo_outline_empty_note,
};
use pipeline::{
build_resolver, changed_paths, read_stdin_diff, resolve_generated_paths, run_base_pipeline,
};
use progress::AnalysisProgress;
use rinkaku_core::render::{Report, render};
use rinkaku_tui::TuiSession;
use spinner::{AnalysisPhase, Spinner};
use splash_progress::SplashProgress;
use std::io::IsTerminal;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp(None)
.format_target(false)
.init();
let cli = Cli::parse();
if let Some(Command::SelfUpdate { yes }) = cli.command {
return self_update::run_self_update(yes);
}
let stdout_is_tty = std::io::stdout().is_terminal();
let display_mode = resolve_display_mode(cli.tui, cli.format, stdout_is_tty);
match display_mode {
DisplayMode::Tui => {
let mut session = TuiSession::init()?;
session.draw_splash(&rinkaku_tui::splash::SplashState::label_only(
spinner::phase_message(AnalysisPhase::Starting),
))?;
let progress = SplashProgress::new(session);
let outcome = run_analysis(&cli, &progress).map(|analyzed| {
let report = finish_report(&cli, &progress, analyzed.report);
(report, analyzed.diff_text, analyzed.resolved_workdir)
});
let (session, buffered_notes) = progress.into_session_and_notes();
let (report, diff_text, resolved_workdir) = match outcome {
Ok(analyzed) => analyzed,
Err(err) => {
drop(session);
flush_notes(buffered_notes);
return Err(err);
}
};
let repo_root = resolve_repo_root(resolved_workdir.as_deref());
let result = session
.run(&report, &diff_text, cli.entry.as_deref(), &repo_root)
.map_err(anyhow::Error::from);
flush_notes(buffered_notes);
result
}
DisplayMode::Output(format) => {
let spinner = Spinner::start(spinner::phase_message(AnalysisPhase::Starting));
let analyzed = run_analysis(&cli, &spinner)?;
spinner.finish_and_clear();
let report = finish_report(&cli, &spinner, analyzed.report);
let output = render(&report, format.into())?;
print!("{output}");
Ok(())
}
}
}
fn flush_notes(notes: Vec<String>) {
for note in notes {
eprintln!("{note}");
}
}
struct AnalyzedReport {
report: Report,
diff_text: String,
resolved_workdir: Option<PathBuf>,
}
fn run_analysis(cli: &Cli, progress: &dyn AnalysisProgress) -> anyhow::Result<AnalyzedReport> {
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();
progress.set_phase(AnalysisPhase::ResolvingPr);
let workdir = resolve_pr_workdir(&parsed)?;
resolved_workdir = workdir.clone();
log::debug!("resolving PR #{number} via gh");
let pr_info = fetch_pr_info(pr_arg.trim())?;
let cwd = workdir.as_deref();
log::debug!("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::debug!("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 {
progress.note(format!(
"warning: 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, progress)?
} else if let Some(base) = &cli.base {
run_base_pipeline(cli, base, &cli.head, None, progress)?
} else if std::io::stdin().is_terminal() {
log::debug!("no diff input and stdin is a terminal; building a whole-repo outline");
progress.set_phase(AnalysisPhase::ParsingRepository);
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 on_file_progress =
|done: usize, total: usize| progress.report_file_progress(done, total);
let report = rinkaku_core::pipeline::analyze_repo(
&paths,
read_working_tree_file,
!cli.exclude_tests,
&generated_paths,
cli.include_generated,
Some(&on_file_progress),
);
if let Some(note) = repo_outline_empty_note(&report) {
progress.note(note.to_string());
}
(report, String::new())
} else {
let diff_text = read_stdin_diff()?;
if diff_text.trim().is_empty() {
progress.note("note: diff is empty, nothing to analyze".to_string());
}
let resolver = build_resolver(
cli,
&diff_text,
read_working_tree_file,
None,
None,
progress,
)?;
let changed_paths = changed_paths(&diff_text)?;
let generated_paths = resolve_generated_paths(cli, &changed_paths, None);
log::debug!("analyzing diff");
progress.set_phase(AnalysisPhase::AnalyzingDiff);
let on_file_progress =
|done: usize, total: usize| progress.report_file_progress(done, total);
let report = rinkaku_core::pipeline::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,
Some(&on_file_progress),
)?;
if let Some(note) = garbage_input_note(&diff_text, &report) {
progress.note(note.to_string());
}
(report, diff_text)
};
Ok(AnalyzedReport {
report,
diff_text,
resolved_workdir,
})
}
fn finish_report(cli: &Cli, progress: &dyn AnalysisProgress, report: Report) -> Report {
if let Some(entry) = &cli.entry {
let pivoted = apply_entry_pivot(report, entry);
if let Some(note) = entry_pivot_empty_note(&pivoted, entry) {
progress.note(note);
}
pivoted
} else {
report
}
}