use clap::Parser;
use color_eyre::Result;
use crate::{app::App, cli::Args, config::Config, state::App as DiffApp};
mod action;
mod app;
mod cli;
mod components;
mod config;
mod diff;
mod errors;
mod event;
mod git;
mod highlight;
mod keys;
mod logging;
mod state;
mod storage;
mod theme;
mod tui;
mod ui;
mod word_diff;
#[tokio::main]
async fn main() -> Result<()> {
crate::errors::init()?;
crate::logging::init()?;
let args = Args::parse();
let user_config = Config::new().unwrap_or_default();
let diff_app = build_diff_app(&args, &user_config)?;
let mut app = App::new(args.tick_rate, args.frame_rate, diff_app, user_config)?;
app.run().await?;
let final_state = app.into_diff_state();
let own_comments: Vec<_> = final_state
.comments
.iter()
.filter(|c| !c.from_pr)
.cloned()
.collect();
storage::save_comments(
&own_comments,
&final_state.range,
&final_state.diff_fingerprint,
);
if !own_comments.is_empty() {
println!("{}\n", final_state.format_all_comments());
}
Ok(())
}
fn build_diff_app(args: &Args, user_config: &Config) -> Result<DiffApp> {
let theme_name = args.theme.or(user_config.theme).unwrap_or_default();
let color_overrides = user_config.color_overrides();
let source = detect_input_source(args);
if let Err(msg) = cli::validate(args, source) {
return Err(color_eyre::eyre::eyre!("{msg}"));
}
let (mut files, range) = match source {
cli::InputSource::Pr => {
let pr_url = args.pr.as_ref().expect("pr source implies --pr");
let head_sha = match git::load_pr_head_sha(pr_url) {
Ok(sha) => Some(sha),
Err(e) => {
eprintln!(
"[shuire] could not resolve PR head commit \
(fold expansion will be unavailable): {e}"
);
None
}
};
let range = cli::DiffRange::PullRequest {
url: pr_url.clone(),
head_sha,
};
let diff_output = git::load_pr_diff(pr_url).map_err(anyhow_to_eyre)?;
let files = diff::parse_unified(&diff_output);
(files, range)
}
cli::InputSource::FromFile => {
let path = args
.from_file
.as_ref()
.expect("FromFile source implies --from-file");
let input = std::fs::read_to_string(path)?;
let range = cli::DiffRange::Stdin;
let files = diff::parse_unified(&input);
(files, range)
}
cli::InputSource::Stdin => {
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
reattach_stdin_to_tty()?;
let range = cli::DiffRange::Stdin;
let files = diff::parse_unified(&input);
(files, range)
}
cli::InputSource::Git => {
let range = cli::resolve_range(args);
let files = git::load_diff(&range, args).map_err(anyhow_to_eyre)?;
(files, range)
}
};
highlight::highlight_files(&mut files);
if !args.auto_viewed.is_empty() {
for file in &mut files {
for pattern in &args.auto_viewed {
if glob_match(pattern, &file.path) {
file.viewed = true;
break;
}
}
}
}
let mut app = DiffApp::new(files, range.clone(), theme_name);
app.color_overrides = color_overrides;
app.no_emoji = args.no_emoji;
app.comment_modal = user_config.comment_modal.unwrap_or(true);
app.show_hints = args
.hints_cli()
.unwrap_or(user_config.show_hints.unwrap_or(true));
if let Some(mode) = args.mode {
app.split_view = matches!(mode, cli::ViewMode::Split);
}
if args.clean {
storage::clear_comments(&range);
eprintln!(
"[shuire] --clean: cleared saved comments for {}",
range.label()
);
} else {
let persisted = storage::load_comments(&range, &app.diff_fingerprint);
app.comments.extend(persisted);
}
for comment_json in &args.comment {
match parse_comment_json(comment_json) {
Ok(comments) => app.comments.extend(comments),
Err(e) => eprintln!("[shuire] --comment: invalid JSON (ignored): {e}"),
}
}
if let Some(ref pr_url) = args.pr {
if !args.no_pr_comments {
match git::load_pr_comments(pr_url) {
Ok(pr_comments) => {
for pc in pr_comments {
if let Some(line) = pc.line {
app.comments.push(state::Comment {
file: pc.path,
side: state::Side::New,
lineno: line,
lineno_end: None,
body: pc.body,
replies: pc.replies,
from_pr: true,
});
}
}
}
Err(e) => eprintln!("[shuire] could not load PR review comments: {e}"),
}
}
}
Ok(app)
}
fn parse_comment_json(json: &str) -> Result<Vec<state::Comment>> {
#[derive(serde::Deserialize)]
struct RawComment {
file: String,
line: u32,
body: String,
}
let raw: Vec<RawComment> = if json.trim().starts_with('[') {
serde_json::from_str(json)?
} else {
vec![serde_json::from_str(json)?]
};
Ok(raw
.into_iter()
.map(|c| state::Comment {
file: c.file,
side: state::Side::New,
lineno: c.line,
lineno_end: None,
body: c.body,
replies: Vec::new(),
from_pr: false,
})
.collect())
}
#[cfg(unix)]
fn reattach_stdin_to_tty() -> Result<()> {
use std::os::fd::AsRawFd;
let tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.map_err(|e| color_eyre::eyre::eyre!("cannot open /dev/tty: {e}"))?;
let ret = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
if ret < 0 {
return Err(color_eyre::eyre::eyre!(
"dup2 /dev/tty -> stdin failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
#[cfg(not(unix))]
fn reattach_stdin_to_tty() -> Result<()> {
Ok(())
}
fn detect_input_source(args: &Args) -> cli::InputSource {
if args.pr.is_some() {
return cli::InputSource::Pr;
}
if args.from_file.is_some() {
return cli::InputSource::FromFile;
}
if args.target == "-" {
return cli::InputSource::Stdin;
}
let has_positional = args.target != "@" || args.compare_with.is_some();
if !has_positional && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return cli::InputSource::Stdin;
}
cli::InputSource::Git
}
fn anyhow_to_eyre(err: anyhow::Error) -> color_eyre::eyre::Report {
color_eyre::eyre::eyre!("{err:#}")
}
fn glob_match(pattern: &str, path: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(ext) = pattern.strip_prefix("*.") {
return path.ends_with(&format!(".{ext}"));
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return path.starts_with(prefix);
}
path.contains(pattern)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_match_star() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*", ""));
}
#[test]
fn glob_match_extension() {
assert!(glob_match("*.rs", "src/main.rs"));
assert!(glob_match("*.rs", "a.rs"));
assert!(!glob_match("*.rs", "a.rst"));
assert!(!glob_match("*.rs", "rs"));
}
#[test]
fn glob_match_directory_prefix() {
assert!(glob_match("vendor/**", "vendor/lib/foo.rs"));
assert!(glob_match("vendor/**", "vendor/"));
assert!(!glob_match("vendor/**", "src/lib/foo.rs"));
}
#[test]
fn glob_match_substring_fallback() {
assert!(glob_match("node_modules", "a/node_modules/b.js"));
assert!(!glob_match("node_modules", "src/main.rs"));
}
#[test]
fn parse_comment_json_single_object() {
let cs = parse_comment_json(r#"{"file":"a.rs","line":10,"body":"hello"}"#).unwrap();
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].file, "a.rs");
assert_eq!(cs[0].lineno, 10);
assert_eq!(cs[0].body, "hello");
assert_eq!(cs[0].side, state::Side::New);
assert!(cs[0].replies.is_empty());
}
#[test]
fn parse_comment_json_array() {
let json = r#"[
{"file":"a.rs","line":1,"body":"x"},
{"file":"b.rs","line":2,"body":"y"}
]"#;
let cs = parse_comment_json(json).unwrap();
assert_eq!(cs.len(), 2);
assert_eq!(cs[0].file, "a.rs");
assert_eq!(cs[1].file, "b.rs");
}
#[test]
fn parse_comment_json_missing_fields_errors() {
assert!(parse_comment_json(r#"{"file":"a.rs","line":1}"#).is_err());
assert!(parse_comment_json("garbage").is_err());
}
#[test]
fn detect_input_source_pr_takes_priority() {
let mut args = cli::Args {
tick_rate: 4.0,
frame_rate: 60.0,
target: "@".to_string(),
compare_with: None,
merge_base: false,
include_untracked: false,
context: None,
theme: None,
pr: Some("https://github.com/a/b/pull/1".to_string()),
from_file: Some("x".into()),
comment: Vec::new(),
auto_viewed: Vec::new(),
no_emoji: false,
mode: None,
clean: false,
hints: false,
no_hints: false,
no_pr_comments: false,
};
assert_eq!(detect_input_source(&args), cli::InputSource::Pr);
args.pr = None;
assert_eq!(detect_input_source(&args), cli::InputSource::FromFile);
}
#[test]
fn detect_input_source_dash_forces_stdin() {
let args = cli::Args {
tick_rate: 4.0,
frame_rate: 60.0,
target: "-".to_string(),
compare_with: None,
merge_base: false,
include_untracked: false,
context: None,
theme: None,
pr: None,
from_file: None,
comment: Vec::new(),
auto_viewed: Vec::new(),
no_emoji: false,
mode: None,
clean: false,
hints: false,
no_hints: false,
no_pr_comments: false,
};
assert_eq!(detect_input_source(&args), cli::InputSource::Stdin);
}
}