pub mod args;
mod bench;
mod commands;
mod manage;
mod render;
mod scope;
mod search;
use std::path::PathBuf;
use clap::Parser;
use crate::Config;
use crate::hook::vendors::{AgentAction, InstallScope};
pub use args::{Cli, ManageCommand};
use bench::cmd_bench_search;
use commands::{AgentCommand, AgentScope, InitArgs};
use manage::{cmd_index, cmd_status, cmd_type_list, cmd_update};
use scope::cmd_files;
use search::{cmd_search, SearchArgs};
pub fn run() -> i32 {
let cli = Cli::parse();
match &cli.command {
Some(ManageCommand::Init(args)) => return cmd_init(args),
Some(ManageCommand::Agent { command }) => return cmd_agent(command),
Some(ManageCommand::Hook { target }) => return crate::hook::protocols::cmd_hook(target),
Some(ManageCommand::Rewrite { cwd, command }) => {
return crate::hook::core::rewrite::cmd_rewrite(command, cwd.as_deref());
}
_ => {}
}
let mut config = resolve_config(&cli);
config.verbose = cli.verbose || cli.debug;
match cli.command {
Some(ManageCommand::Index {
force,
stats,
quiet,
}) => cmd_index(config, force, stats, quiet),
Some(ManageCommand::Status { json }) => cmd_status(config, json),
Some(ManageCommand::Update { flush, quiet }) => cmd_update(config, flush, quiet),
Some(ManageCommand::Init(_))
| Some(ManageCommand::Agent { .. })
| Some(ManageCommand::Hook { .. })
| Some(ManageCommand::Rewrite { .. }) => unreachable!("handled before config resolution"),
Some(ManageCommand::BenchSearch {
queries,
iterations,
warmups,
}) => cmd_bench_search(config, &queries, iterations, warmups),
None => {
if cli.type_list {
return cmd_type_list();
}
if cli.files {
return cmd_files(config, &cli);
}
let globs = cli.combined_globs();
let (pattern, paths) = if !cli.regexp.is_empty() {
let mut p = cli.paths;
if let Some(pos) = cli.pattern {
p.insert(0, PathBuf::from(pos));
}
let combined = if cli.regexp.len() == 1 {
cli.regexp.into_iter().next().unwrap()
} else {
cli.regexp
.iter()
.map(|r| format!("(?:{r})"))
.collect::<Vec<_>>()
.join("|")
};
(combined, p)
} else {
match cli.pattern {
Some(pat) => (pat, cli.paths),
None => {
eprintln!("st: a pattern is required (try `st --help`)");
return 2;
}
}
};
if cli.pcre2 {
eprintln!("st: --pcre2 is not supported; using default regex engine");
}
let ignore_case = if cli.smart_case && !cli.case_sensitive && !cli.ignore_case {
!pattern.chars().any(|c| c.is_uppercase())
} else {
cli.ignore_case
};
let heading = cli.heading || cli.pretty;
let line_number = cli.line_number > 0 || cli.pretty;
let ctx = cli.context.unwrap_or(0);
let search_args = SearchArgs {
pattern,
paths,
fixed_strings: cli.fixed_strings,
ignore_case,
word_regexp: cli.word_regexp,
line_regexp: cli.line_regexp,
line_number,
with_filename: cli.with_filename,
invert_match: cli.invert_match,
files_with_matches: cli.files_with_matches,
files_without_match: cli.files_without_match,
count: cli.count,
count_matches: cli.count_matches,
max_count: cli.max_count,
quiet: cli.quiet,
only_matching: cli.only_matching,
json: cli.json,
heading,
no_line_number: cli.no_line_number > 0,
no_filename: cli.no_filename,
after_context: cli.after_context.unwrap_or(ctx),
before_context: cli.before_context.unwrap_or(ctx),
file_types: cli.file_type,
type_nots: cli.type_not,
globs,
column: cli.column || cli.vimgrep,
vimgrep: cli.vimgrep,
replace: cli.replace,
null: cli.null,
context_separator: cli.context_separator,
byte_offset: cli.byte_offset,
trim: cli.trim,
max_columns: cli.max_columns,
search_stats: cli.search_stats,
max_depth: cli.max_depth,
};
cmd_search(config, &search_args)
}
}
}
fn cmd_init(args: &InitArgs) -> i32 {
let agent = match resolve_init_agent(args) {
Ok(agent) => agent,
Err(err) => {
eprintln!("{err}");
return 2;
}
};
let scope = resolve_init_scope(args, &agent);
crate::hook::vendors::cmd_agent(AgentAction::Install, &agent, scope)
}
fn resolve_init_agent(args: &InitArgs) -> Result<String, String> {
let selected = [
("claude", args.claude),
("cursor", args.cursor),
("copilot", args.copilot),
("gemini", args.gemini),
("opencode", args.opencode),
("openclaw", args.openclaw),
("codex", args.codex),
("cline", args.cline),
("windsurf", args.windsurf),
("kilocode", args.kilocode),
("antigravity", args.antigravity),
]
.into_iter()
.filter_map(|(name, enabled)| enabled.then_some(name))
.collect::<Vec<_>>();
match (args.agent.as_deref(), selected.as_slice()) {
(Some(_), [_first, ..]) => {
Err("st: choose either --agent or one agent shortcut flag, not both".to_string())
}
(Some(agent), []) => Ok(agent.to_string()),
(None, []) => Ok("claude".to_string()),
(None, [agent]) => Ok((*agent).to_string()),
(None, [..]) => Err("st: choose only one agent shortcut flag".to_string()),
}
}
fn resolve_init_scope(args: &InitArgs, agent: &str) -> InstallScope {
if args.scope.global && agent == "copilot" {
return InstallScope::Project;
}
if args.scope.global {
InstallScope::Global
} else {
InstallScope::Project
}
}
fn cmd_agent(command: &AgentCommand) -> i32 {
match command {
AgentCommand::Install { agent, scope } => {
let Some(scope) = resolve_agent_scope(scope) else {
return 2;
};
crate::hook::vendors::cmd_agent(AgentAction::Install, agent, scope)
}
AgentCommand::Uninstall { agent, scope } => {
let Some(scope) = resolve_agent_scope(scope) else {
return 2;
};
crate::hook::vendors::cmd_agent(AgentAction::Uninstall, agent, scope)
}
AgentCommand::Show { agent, scope } => {
let Some(scope) = resolve_agent_scope(scope) else {
return 2;
};
crate::hook::vendors::cmd_agent(AgentAction::Show, agent, scope)
}
}
}
fn resolve_agent_scope(scope: &AgentScope) -> Option<InstallScope> {
match (scope.global, scope.project) {
(true, false) => Some(InstallScope::Global),
(false, true) => Some(InstallScope::Project),
_ => {
eprintln!("st: choose exactly one of --global or --project");
None
}
}
}
const MAX_FILE_SIZE_CEILING: u64 = 1_073_741_824;
fn resolve_config(cli: &Cli) -> Config {
let repo_root = cli
.repo_root
.clone()
.or_else(detect_repo_root)
.unwrap_or_else(|| PathBuf::from("."));
let index_dir = {
let raw = cli
.index_dir
.clone()
.unwrap_or_else(|| repo_root.join(".syntext"));
if let Err(msg) = validate_index_dir(&raw) {
eprintln!("{msg}");
std::process::exit(2);
}
raw
};
let max_file_size = parse_max_file_size();
Config {
max_file_size,
max_segments: 10,
index_dir,
repo_root,
verbose: false,
strict_permissions: true,
}
}
fn parse_max_file_size() -> u64 {
clamp_max_file_size(
std::env::var("SYNTEXT_MAX_FILE_SIZE")
.ok()
.and_then(|v| v.parse::<u64>().ok()),
)
}
fn clamp_max_file_size(raw: Option<u64>) -> u64 {
raw.unwrap_or(10 * 1024 * 1024).min(MAX_FILE_SIZE_CEILING)
}
fn detect_repo_root() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join(".git").exists() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
#[cfg(unix)]
fn validate_index_dir(index_dir: &std::path::Path) -> Result<(), String> {
if !index_dir.is_absolute() {
return Ok(());
}
const SENSITIVE_PREFIXES: &[&str] = &[
"/etc",
"/usr",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/sys",
"/proc",
"/dev",
"/boot",
"/root",
"/System",
"/Library",
"/private/etc",
"/private/var/root",
];
let dir_str = index_dir.to_string_lossy();
if let Some(matched) = overlaps_sensitive_prefix(&dir_str, SENSITIVE_PREFIXES, '/') {
return Err(format!(
"st: refusing --index-dir '{}': overlaps system path '{matched}'; \
use a path under the repository or a user-owned directory",
index_dir.display(),
));
}
Ok(())
}
#[cfg(not(unix))]
fn validate_index_dir(index_dir: &std::path::Path) -> Result<(), String> {
if !index_dir.is_absolute() {
return Ok(());
}
let mut sensitive: Vec<String> = Vec::new();
for var in [
"SYSTEMROOT",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMDATA",
] {
if let Some(val) = std::env::var_os(var) {
let lower = val.to_string_lossy().to_lowercase().replace('/', "\\");
if !sensitive.contains(&lower) {
sensitive.push(lower);
}
}
}
for fb in [
"c:\\windows",
"c:\\program files",
"c:\\program files (x86)",
"c:\\programdata",
] {
let s = fb.to_string();
if !sensitive.contains(&s) {
sensitive.push(s);
}
}
let dir_lower = index_dir
.to_string_lossy()
.to_lowercase()
.replace('/', "\\");
let prefixes: Vec<&str> = sensitive.iter().map(|s| s.as_str()).collect();
if let Some(matched) = overlaps_sensitive_prefix(&dir_lower, &prefixes, '\\') {
return Err(format!(
"st: refusing --index-dir '{}': overlaps system path '{matched}'; \
use a path under the repository or a user-owned directory",
index_dir.display(),
));
}
Ok(())
}
fn overlaps_sensitive_prefix<'a>(dir: &str, prefixes: &[&'a str], sep: char) -> Option<&'a str> {
for &prefix in prefixes {
if dir == prefix || dir.starts_with(&format!("{prefix}{sep}")) {
return Some(prefix);
}
}
None
}
#[cfg(test)]
mod tests;