use std::io::Write;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum HumanJsonFormat {
#[default]
Human,
Json,
}
impl HumanJsonFormat {
pub fn resolve(format: Option<Self>, json_flag: bool) -> (Self, bool) {
let resolved = if json_flag {
Self::Json
} else {
format.unwrap_or(Self::Human)
};
(resolved, resolved == Self::Json)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum HumanJsonSarifFormat {
#[default]
Human,
Json,
Sarif,
}
impl HumanJsonSarifFormat {
pub fn resolve(format: Option<Self>, json_flag: bool, sarif_flag: bool) -> (Self, bool, bool) {
let resolved = if json_flag {
Self::Json
} else if sarif_flag {
Self::Sarif
} else {
format.unwrap_or(Self::Human)
};
(resolved, resolved == Self::Json, resolved == Self::Sarif)
}
}
pub fn suggest_closest<'a>(
query: &str,
candidates: &[&'a str],
max_distance: usize,
) -> Option<&'a str> {
candidates
.iter()
.map(|c| (*c, tirith_core::util::levenshtein(query, c)))
.filter(|(_, d)| *d <= max_distance)
.min_by_key(|(_, d)| *d)
.map(|(c, _)| c)
}
pub fn confirm(prompt: &str, yes: bool) -> bool {
if yes {
return true;
}
if !is_terminal::is_terminal(std::io::stderr()) {
eprintln!("tirith: skipping prompt (not a TTY — use --yes to auto-approve)");
return false;
}
eprint!("{prompt} [y/N] ");
let _ = std::io::stderr().flush();
let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
Ok(_) => matches!(input.trim(), "y" | "Y" | "yes" | "Yes"),
Err(e) => {
eprintln!("tirith: could not read confirmation input: {e}");
false
}
}
}
pub mod audit;
pub mod check;
pub mod checkpoint;
pub mod completions;
pub mod daemon;
pub mod diff;
pub mod doctor;
pub mod explain;
pub mod gateway;
pub mod hook_event;
pub mod init;
pub mod last_trigger;
pub mod license_cmd;
pub mod manpage;
pub mod mcp_server;
pub mod paste;
pub mod policy;
pub mod receipt;
pub mod scan;
pub mod score;
pub mod threatdb_cmd;
pub mod trust;
pub mod warnings;
pub mod why;
#[cfg(unix)]
pub mod fetch;
#[cfg(unix)]
pub mod run;
pub mod setup;
#[cfg(test)]
pub(crate) mod test_harness;
#[cfg(any(test, windows))]
fn trim_wrapping_quotes(value: &str) -> &str {
let bytes = value.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
&value[1..value.len() - 1]
} else {
value
}
}
#[cfg(any(test, windows))]
fn parse_shim_target(contents: &str) -> Option<std::path::PathBuf> {
contents.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("path") {
return None;
}
let value = trim_wrapping_quotes(value.trim());
if value.is_empty() {
return None;
}
Some(std::path::PathBuf::from(value))
})
}
#[cfg(any(test, windows))]
fn resolve_shim_target(path: &std::path::Path) -> Option<std::path::PathBuf> {
let mut sidecar = path.to_path_buf();
sidecar.set_extension("shim");
let contents = std::fs::read_to_string(&sidecar).ok()?;
let target = parse_shim_target(&contents)?;
let target = if target.is_relative() {
sidecar.parent()?.join(target)
} else {
target
};
target.canonicalize().ok().or(Some(target))
}
#[cfg(unix)]
fn npm_platform_package() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some("tirith-linux-x64"),
("linux", "aarch64") => Some("tirith-linux-arm64"),
("macos", "x86_64") => Some("tirith-darwin-x64"),
("macos", "aarch64") => Some("tirith-darwin-arm64"),
_ => None,
}
}
#[cfg(unix)]
fn resolve_npm_wrapper_target(path: &std::path::Path) -> Option<std::path::PathBuf> {
use std::path::Component;
let canonical = path.canonicalize().ok()?;
let components: Vec<Component> = canonical.components().collect();
if components.len() < 4 {
return None;
}
let tail = &components[components.len() - 4..];
let expected = [
Component::Normal("node_modules".as_ref()),
Component::Normal("tirith".as_ref()),
Component::Normal("bin".as_ref()),
Component::Normal("tirith".as_ref()),
];
if tail != expected {
return None;
}
let node_modules = canonical.ancestors().nth(3)?;
let platform = npm_platform_package()?;
let native = node_modules
.join("@sheeki03")
.join(platform)
.join("bin")
.join("tirith");
if !native.is_file() {
return None;
}
native.canonicalize().ok()
}
fn resolve_effective_tirith_target(path: &std::path::Path) -> Option<std::path::PathBuf> {
#[cfg(windows)]
if let Some(target) = resolve_shim_target(path) {
return Some(target);
}
#[cfg(unix)]
if let Some(target) = resolve_npm_wrapper_target(path) {
return Some(target);
}
path.canonicalize().ok()
}
pub fn tirith_path_lookup_command() -> &'static str {
#[cfg(unix)]
{
"which -a tirith"
}
#[cfg(not(unix))]
{
"where.exe tirith"
}
}
pub fn resolve_tirith_on_path() -> Vec<std::path::PathBuf> {
let output = {
#[cfg(unix)]
{
std::process::Command::new("sh")
.args(["-c", "which -a tirith 2>/dev/null"])
.output()
}
#[cfg(not(unix))]
{
std::process::Command::new("where.exe")
.arg("tirith")
.output()
}
};
let output = match output {
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(std::path::PathBuf::from)
.collect()
}
pub fn find_shadow_binaries() -> Vec<String> {
let our_canonical = std::env::current_exe()
.ok()
.and_then(|p| resolve_effective_tirith_target(&p));
let mut seen = std::collections::HashSet::new();
let mut shadows = Vec::new();
for path in resolve_tirith_on_path() {
let canonical = resolve_effective_tirith_target(&path);
if let (Some(ours), Some(ref theirs)) = (&our_canonical, &canonical) {
if ours == theirs {
continue;
}
}
let key = canonical
.map(|c| c.display().to_string())
.unwrap_or_else(|| path.display().to_string());
if seen.insert(key) {
shadows.push(path.display().to_string());
}
}
shadows
}
#[cfg(test)]
mod tests {
use super::{parse_shim_target, resolve_shim_target};
use std::fs;
use std::path::PathBuf;
#[test]
fn parse_shim_target_accepts_unquoted_values() {
let parsed =
parse_shim_target("path = C:\\Users\\alice\\scoop\\apps\\tirith\\current\\tirith.exe");
assert_eq!(
parsed,
Some(PathBuf::from(
"C:\\Users\\alice\\scoop\\apps\\tirith\\current\\tirith.exe"
))
);
}
#[test]
fn parse_shim_target_accepts_case_insensitive_quoted_values() {
let parsed = parse_shim_target("ARGS = --help\r\nPATH = \"/tmp/tirith.exe\"\r\n");
assert_eq!(parsed, Some(PathBuf::from("/tmp/tirith.exe")));
}
#[test]
fn resolve_shim_target_uses_absolute_target_from_sidecar() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("apps/tirith/current/tirith.exe");
let shim = dir.path().join("shims/tirith.exe");
fs::create_dir_all(real.parent().unwrap()).unwrap();
fs::create_dir_all(shim.parent().unwrap()).unwrap();
fs::write(&real, b"real").unwrap();
fs::write(&shim, b"shim").unwrap();
fs::write(
shim.with_extension("shim"),
format!("path = \"{}\"\n", real.display()),
)
.unwrap();
assert_eq!(
resolve_shim_target(&shim).unwrap().canonicalize().unwrap(),
real.canonicalize().unwrap()
);
}
#[test]
fn resolve_shim_target_uses_relative_target_from_sidecar() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("apps/tirith/current/tirith.exe");
let shim = dir.path().join("shims/tirith.exe");
fs::create_dir_all(real.parent().unwrap()).unwrap();
fs::create_dir_all(shim.parent().unwrap()).unwrap();
fs::write(&real, b"real").unwrap();
fs::write(&shim, b"shim").unwrap();
fs::write(
shim.with_extension("shim"),
"path = ../apps/tirith/current/tirith.exe\n",
)
.unwrap();
assert_eq!(
resolve_shim_target(&shim).unwrap().canonicalize().unwrap(),
real.canonicalize().unwrap()
);
}
#[cfg(unix)]
mod npm_wrapper_tests {
use super::super::{npm_platform_package, resolve_npm_wrapper_target};
use std::fs;
use std::os::unix::fs::symlink;
fn build_layout(
root: &std::path::Path,
) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let platform = npm_platform_package()?;
let wrapper_dir = root.join("lib/node_modules/tirith/bin");
let native_dir = root
.join("lib/node_modules/@sheeki03")
.join(platform)
.join("bin");
let bin_dir = root.join("bin");
fs::create_dir_all(&wrapper_dir).unwrap();
fs::create_dir_all(&native_dir).unwrap();
fs::create_dir_all(&bin_dir).unwrap();
let wrapper = wrapper_dir.join("tirith");
let native = native_dir.join("tirith");
fs::write(&wrapper, b"#!/usr/bin/env node\n// wrapper").unwrap();
fs::write(&native, b"\x7fELF native bytes").unwrap();
let symlinked = bin_dir.join("tirith");
symlink(&wrapper, &symlinked).unwrap();
Some((symlinked, native))
}
#[test]
fn resolve_npm_wrapper_target_via_symlink_resolves_native_binary() {
let dir = tempfile::tempdir().unwrap();
let Some((symlinked, native)) = build_layout(dir.path()) else {
eprintln!("skipping: npm distribution doesn't ship for this Unix target");
return;
};
assert_eq!(
resolve_npm_wrapper_target(&symlinked),
Some(native.canonicalize().unwrap())
);
}
#[test]
fn resolve_npm_wrapper_target_resolves_native_binary_when_called_with_wrapper_path() {
let dir = tempfile::tempdir().unwrap();
let Some((_symlinked, native)) = build_layout(dir.path()) else {
eprintln!("skipping: npm distribution doesn't ship for this Unix target");
return;
};
let wrapper = dir.path().join("lib/node_modules/tirith/bin/tirith");
assert_eq!(
resolve_npm_wrapper_target(&wrapper),
Some(native.canonicalize().unwrap())
);
}
#[test]
fn resolve_npm_wrapper_target_returns_none_when_native_missing() {
let dir = tempfile::tempdir().unwrap();
let wrapper_dir = dir.path().join("lib/node_modules/tirith/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let wrapper = wrapper_dir.join("tirith");
fs::write(&wrapper, b"wrapper").unwrap();
assert_eq!(resolve_npm_wrapper_target(&wrapper), None);
}
#[test]
fn resolve_npm_wrapper_target_ignores_non_npm_paths() {
let dir = tempfile::tempdir().unwrap();
let pip_dir = dir.path().join("local/bin");
fs::create_dir_all(&pip_dir).unwrap();
let pip = pip_dir.join("tirith");
fs::write(&pip, b"pip-installed").unwrap();
assert_eq!(resolve_npm_wrapper_target(&pip), None);
}
}
}