mod compound;
mod engine;
mod handlers;
mod hook;
mod rules;
mod suggest;
mod types;
use std::io::{self, BufRead, IsTerminal, Read};
use std::process::ExitCode;
use compound::{split_compound, try_rewrite_compound};
use engine::try_rewrite;
use hook::{parse_agent_flag, run_hook_mode};
use suggest::{print_help, print_suggest};
use types::{CompoundSplitResult, RewriteCategory, RewriteResult};
pub(super) use suggest::command;
pub(crate) fn would_rewrite(command: &str) -> Option<String> {
let command = command.trim();
if command.is_empty() || command.starts_with("skim ") {
return None;
}
let has_operator_chars = command.contains("&&")
|| command.contains("||")
|| command.contains(';')
|| command.contains('|');
if !has_operator_chars {
let tokens: Vec<&str> = command.split_whitespace().collect();
return try_rewrite(&tokens).map(|r| r.tokens.join(" "));
}
match split_compound(command) {
CompoundSplitResult::Bail => None,
CompoundSplitResult::Simple(tokens) => {
let refs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
try_rewrite(&refs).map(|r| r.tokens.join(" "))
}
CompoundSplitResult::Compound(segments) => {
try_rewrite_compound(&segments).map(|r| r.tokens.join(" "))
}
}
}
pub(crate) fn run(args: &[String]) -> anyhow::Result<ExitCode> {
if args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) {
print_help();
return Ok(ExitCode::SUCCESS);
}
if args.iter().any(|a| a == "--hook") {
let agent = parse_agent_flag(args);
return run_hook_mode(agent);
}
let suggest_mode = args.first().is_some_and(|a| a == "--suggest");
let positional_start = if suggest_mode { 1 } else { 0 };
let positional_args: Vec<&str> = args[positional_start..]
.iter()
.map(|s| s.as_str())
.collect();
let tokens: Vec<String> = if positional_args.is_empty() {
if io::stdin().is_terminal() {
return emit_result(suggest_mode, "", None, false);
}
let mut line = String::new();
io::BufReader::new(io::stdin().lock().take(4096)).read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() {
return emit_result(suggest_mode, "", None, false);
}
trimmed.split_whitespace().map(String::from).collect()
} else {
positional_args.iter().map(|s| s.to_string()).collect()
};
if tokens.is_empty() {
return emit_result(suggest_mode, "", None, false);
}
let original = tokens.join(" ");
let has_operator_chars = original.contains("&&")
|| original.contains("||")
|| original.contains(';')
|| original.contains('|');
if !has_operator_chars {
let token_refs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
let result = try_rewrite(&token_refs);
return emit_rewrite_result(suggest_mode, &original, result, false);
}
match split_compound(&original) {
CompoundSplitResult::Bail => emit_result(suggest_mode, &original, None, false),
CompoundSplitResult::Simple(simple_tokens) => {
let token_refs: Vec<&str> = simple_tokens.iter().map(|s| s.as_str()).collect();
let result = try_rewrite(&token_refs);
emit_rewrite_result(suggest_mode, &original, result, false)
}
CompoundSplitResult::Compound(segments) => {
let result = try_rewrite_compound(&segments);
emit_rewrite_result(suggest_mode, &original, result, true)
}
}
}
fn emit_result(
suggest_mode: bool,
original: &str,
result: Option<(&str, RewriteCategory)>,
compound: bool,
) -> anyhow::Result<ExitCode> {
if suggest_mode {
print_suggest(original, result, compound);
return Ok(ExitCode::SUCCESS);
}
match result {
Some((rewritten, _)) => {
println!("{rewritten}");
Ok(ExitCode::SUCCESS)
}
None => Ok(ExitCode::FAILURE),
}
}
fn emit_rewrite_result(
suggest_mode: bool,
original: &str,
result: Option<RewriteResult>,
compound: bool,
) -> anyhow::Result<ExitCode> {
let rewritten = result.as_ref().map(|r| r.tokens.join(" "));
let match_info = result
.as_ref()
.zip(rewritten.as_ref())
.map(|(r, s)| (s.as_str(), r.category));
emit_result(suggest_mode, original, match_info, compound)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_would_rewrite_git_status_with_s() {
assert_eq!(
would_rewrite("git status -s"),
Some("skim git status -s".to_string()),
"git status -s should rewrite (handler strips -s)"
);
}
#[test]
fn test_would_rewrite_git_log_oneline() {
let result = would_rewrite("git log --oneline -5");
assert!(
result.is_some(),
"git log --oneline -5 should rewrite (handler strips --oneline)"
);
let rewritten = result.unwrap();
assert!(
rewritten.starts_with("skim git log"),
"Expected 'skim git log ...' prefix, got: {rewritten}"
);
}
#[test]
fn test_would_rewrite_already_skim_returns_none() {
assert_eq!(
would_rewrite("skim git status"),
None,
"Already-skim commands must not be rewritten"
);
}
#[test]
fn test_would_rewrite_empty_returns_none() {
assert_eq!(would_rewrite(""), None, "Empty input must return None");
assert_eq!(
would_rewrite(" "),
None,
"Whitespace-only input must return None"
);
}
#[test]
fn test_would_rewrite_non_rewritable_returns_none() {
assert_eq!(
would_rewrite("python3 -c 'print(1)'"),
None,
"python3 -c is not a rewritable pattern"
);
}
#[test]
fn test_would_rewrite_justified_skip_returns_none() {
assert_eq!(
would_rewrite("git diff --stat"),
None,
"git diff --stat is a justified skip"
);
}
#[test]
fn test_would_rewrite_gh_pr_list_json_rewrites() {
let result = would_rewrite("gh pr list --json number");
assert!(result.is_some(), "gh pr list --json should now rewrite");
let rewritten = result.unwrap();
assert!(
rewritten.contains("skim infra gh pr list"),
"Expected 'skim infra gh pr list' in output, got: {rewritten}"
);
}
#[test]
fn test_would_rewrite_jest_rewrites() {
assert_eq!(
would_rewrite("jest src/"),
Some("skim test jest src/".to_string()),
"jest should rewrite to skim test jest"
);
}
#[test]
fn test_would_rewrite_npx_jest_rewrites() {
assert_eq!(
would_rewrite("npx jest src/"),
Some("skim test jest src/".to_string()),
"npx jest should rewrite to skim test jest"
);
}
}