mod browser;
mod cli;
mod clipboard;
mod display;
mod generated_paths;
mod git;
mod github;
mod log_writer;
mod notes;
mod pipeline;
mod progress;
mod self_update;
mod spinner;
mod splash_progress;
mod update_prompt;
#[cfg(test)]
mod test_util;
use browser::SystemBrowserOpener;
use clap::Parser;
use cli::{Cli, Command};
use clipboard::SystemClipboard;
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::{PrArg, parse_pr_arg};
use github::pr_info::{ensure_fetched_head_matches, fetch_pr_info};
use github::remote::{git_remote_origin_url, parse_github_remote};
use github::review::GhReviewSubmitter;
use github::workdir::resolve_pr_workdir;
use log_writer::DeferredLogSink;
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 rinkaku_tui::locale::detect_locale;
use rinkaku_tui::review::PrContext;
use spinner::{AnalysisPhase, Spinner};
use splash_progress::SplashProgress;
use std::io::IsTerminal;
use std::path::PathBuf;
fn logger_builder() -> env_logger::Builder {
let mut builder =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"));
builder.format_timestamp(None).format_target(false);
builder
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if let Some(Command::SelfUpdate { yes }) = cli.command {
logger_builder().init();
return self_update::run_self_update(yes, self_update::Announcement::Print).map(|_| ());
}
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 log_sink = DeferredLogSink::new();
logger_builder()
.target(env_logger::Target::Pipe(Box::new(log_sink.clone())))
.init();
let _log_sink_guard = log_writer::ReleaseGuard::new(log_sink.clone(), std::io::stderr);
let update_check = if std::env::var("RINKAKU_UPDATE_CHECK").as_deref() != Ok("0") {
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Some(version) = self_update::check_update_available() {
let _ = sender.send(version);
}
});
Some(receiver)
} else {
None
};
let before_reexec = || release_log_sink(&log_sink);
let update_check =
match update_prompt::offer_pre_analysis_update(update_check, before_reexec)? {
update_prompt::PreAnalysisOutcome::NotAsked(receiver) => receiver,
update_prompt::PreAnalysisOutcome::Declined => None,
};
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,
analyzed.pr_head_sha,
analyzed.pr_context,
)
});
let (session, buffered_notes) = progress.into_session_and_notes();
let (report, diff_text, resolved_workdir, pr_head_sha, pr_context) = match outcome {
Ok(analyzed) => analyzed,
Err(err) => {
drop(session);
release_log_sink(&log_sink);
flush_notes(buffered_notes);
return Err(err);
}
};
let repo_root = resolve_repo_root(resolved_workdir.as_deref());
let pr_source_reader = pr_head_sha.map(|head| git::file_read::PrHeadSourceReader {
head,
cwd: resolved_workdir.clone(),
});
let source_reader: &dyn rinkaku_tui::source::SourceReader = match &pr_source_reader {
Some(reader) => reader,
None => &rinkaku_tui::source::WorkingTreeSourceReader,
};
let submitter = pr_context.is_some().then_some(&GhReviewSubmitter as _);
let system_clipboard = SystemClipboard::detect();
let system_browser = SystemBrowserOpener;
let review_ports = rinkaku_tui::ReviewPorts {
pr_context,
submitter,
clipboard: &system_clipboard,
browser: &system_browser,
};
let locale = detect_locale(
std::env::var("LC_ALL").ok().as_deref(),
std::env::var("LC_MESSAGES").ok().as_deref(),
std::env::var("LANG").ok().as_deref(),
);
let run_result = session.run(
&report,
&diff_text,
cli.entry.as_deref(),
&repo_root,
source_reader,
review_ports,
update_check,
locale,
);
release_log_sink(&log_sink);
flush_notes(buffered_notes);
let update_requested = run_result.map_err(anyhow::Error::from)?;
if update_requested {
self_update::run_self_update(true, self_update::Announcement::Print).map(|_| ())
} else {
Ok(())
}
}
DisplayMode::Output(format) => {
logger_builder().init();
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}");
}
}
fn release_log_sink(sink: &DeferredLogSink<std::io::Stderr>) {
let _ = sink.release(std::io::stderr());
}
struct AnalyzedReport {
report: Report,
diff_text: String,
resolved_workdir: Option<PathBuf>,
pr_head_sha: Option<String>,
pr_context: Option<PrContext>,
}
fn run_analysis(cli: &Cli, progress: &dyn AnalysisProgress) -> anyhow::Result<AnalyzedReport> {
let mut resolved_workdir: Option<std::path::PathBuf> = None;
let mut pr_head_sha: Option<String> = None;
let (report, diff_text, pr_context) = 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)?;
pr_head_sha = Some(head_sha.clone());
ensure_fetched_head_matches(number, &head_sha, &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,
));
}
let (report, diff_text) = run_base_pipeline(cli, &base_sha, &head_sha, cwd, progress)?;
let pr_context = resolve_pr_context(&parsed, cwd, number, head_sha);
(report, diff_text, pr_context)
} else if let Some(base) = &cli.base {
let (report, diff_text) = run_base_pipeline(cli, base, &cli.head, None, progress)?;
(report, diff_text, None)
} 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(), None)
} 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, None)
};
Ok(AnalyzedReport {
report,
diff_text,
resolved_workdir,
pr_head_sha,
pr_context,
})
}
fn resolve_pr_context(
parsed: &PrArg,
cwd: Option<&std::path::Path>,
number: u64,
head_sha: String,
) -> Option<PrContext> {
let (owner, repo) = match parsed {
PrArg::Url { owner, repo, .. } => (owner.clone(), repo.clone()),
PrArg::Number(_) => {
let origin = git_remote_origin_url(cwd).ok().flatten()?;
parse_github_remote(&origin)?
}
};
Some(PrContext {
owner,
repo,
number,
head_sha,
})
}
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
}
}