341 lines
9.5 KiB
Rust
341 lines
9.5 KiB
Rust
mod abort_signal;
|
|
mod clipboard;
|
|
mod command;
|
|
mod crypto;
|
|
mod html_to_md;
|
|
mod input;
|
|
mod loader;
|
|
mod logs;
|
|
pub mod native;
|
|
mod path;
|
|
mod render_prompt;
|
|
mod request;
|
|
mod spinner;
|
|
mod variables;
|
|
|
|
pub use self::abort_signal::*;
|
|
pub use self::clipboard::set_text;
|
|
pub use self::command::*;
|
|
pub use self::crypto::*;
|
|
pub use self::html_to_md::*;
|
|
pub use self::input::*;
|
|
pub use self::loader::*;
|
|
pub use self::logs::*;
|
|
pub use self::path::*;
|
|
pub use self::render_prompt::render_prompt;
|
|
pub use self::request::*;
|
|
pub use self::spinner::*;
|
|
pub use self::variables::*;
|
|
|
|
use anyhow::{Context, Result};
|
|
use fancy_regex::Regex;
|
|
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
|
use is_terminal::IsTerminal;
|
|
use nu_ansi_term::Color;
|
|
use serde_json::Value;
|
|
use std::borrow::Cow;
|
|
use std::collections::VecDeque;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::{LazyLock, Mutex, OnceLock};
|
|
use std::{cmp, env, path::PathBuf, process};
|
|
use syntect::highlighting::{Highlighter, Theme};
|
|
use syntect::parsing::Scope;
|
|
|
|
pub static CODE_BLOCK_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"(?ms)```\w*(.*)```").unwrap());
|
|
pub static THINK_TAG_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap());
|
|
pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
|
|
pub static HEADLESS: AtomicBool = AtomicBool::new(false);
|
|
pub static ACP_SERVER: AtomicBool = AtomicBool::new(false);
|
|
|
|
static ACP_PERMISSION_QUEUE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
|
|
|
|
pub fn queue_acp_permission(notification: Value) {
|
|
if let Ok(mut q) = ACP_PERMISSION_QUEUE.lock() {
|
|
q.push_back(notification);
|
|
}
|
|
}
|
|
|
|
pub fn drain_acp_permissions() -> Vec<Value> {
|
|
ACP_PERMISSION_QUEUE
|
|
.lock()
|
|
.map(|mut q| q.drain(..).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
pub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| {
|
|
env::var("NO_COLOR")
|
|
.ok()
|
|
.and_then(|v| parse_bool(&v))
|
|
.unwrap_or_default()
|
|
|| !*IS_STDOUT_TERMINAL
|
|
});
|
|
|
|
static TOOL_DIM_COLOR: OnceLock<Color> = OnceLock::new();
|
|
static TOOL_FN_COLOR: OnceLock<Color> = OnceLock::new();
|
|
static TOOL_KEY_COLOR: OnceLock<Color> = OnceLock::new();
|
|
static TOOL_WARN_COLOR: OnceLock<Color> = OnceLock::new();
|
|
static REPLAY_LABEL_COLOR: OnceLock<Color> = OnceLock::new();
|
|
|
|
pub fn init_tool_colors(theme: &Theme) {
|
|
fn resolve(theme: &Theme, scope_str: &str) -> Option<Color> {
|
|
let scope = Scope::new(scope_str).ok()?;
|
|
let style = Highlighter::new(theme).style_mod_for_stack(&[scope]);
|
|
let fg = style.foreground.or(theme.settings.foreground)?;
|
|
let mute = |ch: u8| -> u8 { ((ch as u16 + 128) / 2) as u8 };
|
|
Some(Color::Rgb(mute(fg.r), mute(fg.g), mute(fg.b)))
|
|
}
|
|
if let Some(c) = resolve(theme, "comment") {
|
|
let _ = TOOL_DIM_COLOR.set(c);
|
|
}
|
|
if let Some(c) = resolve(theme, "support.function") {
|
|
let _ = TOOL_FN_COLOR.set(c);
|
|
}
|
|
if let Some(c) = resolve(theme, "constant.numeric") {
|
|
let _ = TOOL_KEY_COLOR.set(c);
|
|
}
|
|
if let Some(c) = resolve(theme, "string") {
|
|
let _ = TOOL_WARN_COLOR.set(c);
|
|
}
|
|
let replay_color = Scope::new("entity.name")
|
|
.ok()
|
|
.and_then(|scope| {
|
|
let style = Highlighter::new(theme).style_mod_for_stack(&[scope]);
|
|
style.foreground.or(theme.settings.foreground)
|
|
})
|
|
.map(|fg| Color::Rgb(fg.r, fg.g, fg.b));
|
|
if let Some(c) = replay_color {
|
|
let _ = REPLAY_LABEL_COLOR.set(c);
|
|
}
|
|
}
|
|
|
|
pub fn now() -> String {
|
|
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false)
|
|
}
|
|
|
|
pub fn now_timestamp() -> i64 {
|
|
chrono::Local::now().timestamp()
|
|
}
|
|
|
|
pub fn get_env_name(key: &str) -> String {
|
|
format!("{}_{key}", env!("CARGO_CRATE_NAME"),).to_ascii_uppercase()
|
|
}
|
|
|
|
pub fn normalize_env_name(value: &str) -> String {
|
|
value.replace('-', "_").to_ascii_uppercase()
|
|
}
|
|
|
|
pub fn parse_bool(value: &str) -> Option<bool> {
|
|
match value {
|
|
"1" | "true" => Some(true),
|
|
"0" | "false" => Some(false),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn estimate_token_length(text: &str) -> usize {
|
|
let weighted: usize = text.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum();
|
|
weighted.div_ceil(4)
|
|
}
|
|
|
|
pub fn strip_think_tag(text: &str) -> Cow<'_, str> {
|
|
THINK_TAG_RE.replace_all(text, "")
|
|
}
|
|
|
|
pub fn extract_code_block(text: &str) -> &str {
|
|
CODE_BLOCK_RE
|
|
.captures(text)
|
|
.ok()
|
|
.and_then(|v| v?.get(1).map(|v| v.as_str().trim()))
|
|
.unwrap_or(text)
|
|
}
|
|
|
|
pub fn convert_option_string(value: &str) -> Option<String> {
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value.to_string())
|
|
}
|
|
}
|
|
|
|
pub fn fuzzy_filter<T, F>(values: Vec<T>, get: F, pattern: &str) -> Vec<T>
|
|
where
|
|
F: Fn(&T) -> &str,
|
|
{
|
|
let matcher = SkimMatcherV2::default();
|
|
let mut list: Vec<(T, i64)> = values
|
|
.into_iter()
|
|
.filter_map(|v| {
|
|
let score = matcher.fuzzy_match(get(&v), pattern)?;
|
|
Some((v, score))
|
|
})
|
|
.collect();
|
|
list.sort_unstable_by_key(|b| cmp::Reverse(b.1));
|
|
list.into_iter().map(|(v, _)| v).collect()
|
|
}
|
|
|
|
pub fn pretty_error(err: &anyhow::Error) -> String {
|
|
let mut output = vec![];
|
|
output.push(format!("Error: {err}"));
|
|
let causes: Vec<_> = err.chain().skip(1).collect();
|
|
let causes_len = causes.len();
|
|
if causes_len > 0 {
|
|
output.push("\nCaused by:".to_string());
|
|
if causes_len == 1 {
|
|
output.push(format!(" {}", indent_text(causes[0], 4).trim()));
|
|
} else {
|
|
for (i, cause) in causes.into_iter().enumerate() {
|
|
output.push(format!("{i:5}: {}", indent_text(cause, 7).trim()));
|
|
}
|
|
}
|
|
}
|
|
output.join("\n")
|
|
}
|
|
|
|
pub fn indent_text<T: ToString>(s: T, size: usize) -> String {
|
|
let indent_str = " ".repeat(size);
|
|
s.to_string()
|
|
.split('\n')
|
|
.map(|line| format!("{indent_str}{line}"))
|
|
.collect::<Vec<String>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn error_text(input: &str) -> String {
|
|
color_text(input, Color::Red)
|
|
}
|
|
|
|
pub fn warning_text(input: &str) -> String {
|
|
color_text(input, Color::Yellow)
|
|
}
|
|
|
|
pub fn muted_warning_text(input: &str) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
let color = TOOL_WARN_COLOR.get().copied().unwrap_or(Color::Fixed(136));
|
|
color.paint(input).to_string()
|
|
}
|
|
|
|
pub fn color_text(input: &str, color: Color) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
nu_ansi_term::Style::new()
|
|
.fg(color)
|
|
.paint(input)
|
|
.to_string()
|
|
}
|
|
|
|
pub fn dimmed_text(input: &str) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
let color = TOOL_DIM_COLOR.get().copied().unwrap_or(Color::Fixed(243));
|
|
color.paint(input).to_string()
|
|
}
|
|
|
|
pub fn cyan_bold_text(input: &str) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
let color = TOOL_FN_COLOR.get().copied().unwrap_or(Color::Fixed(73));
|
|
nu_ansi_term::Style::new()
|
|
.fg(color)
|
|
.bold()
|
|
.paint(input)
|
|
.to_string()
|
|
}
|
|
|
|
pub fn replay_label_text(input: &str) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
let color = REPLAY_LABEL_COLOR.get().copied().unwrap_or(Color::Green);
|
|
nu_ansi_term::Style::new()
|
|
.fg(color)
|
|
.bold()
|
|
.paint(input)
|
|
.to_string()
|
|
}
|
|
|
|
pub fn magenta_text(input: &str) -> String {
|
|
if *NO_COLOR {
|
|
return input.to_string();
|
|
}
|
|
let color = TOOL_KEY_COLOR.get().copied().unwrap_or(Color::Fixed(133));
|
|
color.paint(input).to_string()
|
|
}
|
|
|
|
pub fn multiline_text(input: &str) -> String {
|
|
input
|
|
.split('\n')
|
|
.enumerate()
|
|
.map(|(i, v)| {
|
|
if i == 0 {
|
|
v.to_string()
|
|
} else {
|
|
format!(".. {v}")
|
|
}
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn temp_file(prefix: &str, suffix: &str) -> PathBuf {
|
|
env::temp_dir().join(format!(
|
|
"{}-{}{prefix}{}{suffix}",
|
|
env!("CARGO_CRATE_NAME").to_lowercase(),
|
|
process::id(),
|
|
uuid::Uuid::new_v4()
|
|
))
|
|
}
|
|
|
|
pub fn is_url(path: &str) -> bool {
|
|
path.starts_with("http://") || path.starts_with("https://")
|
|
}
|
|
|
|
pub fn set_proxy(
|
|
mut builder: reqwest::ClientBuilder,
|
|
proxy: &str,
|
|
) -> Result<reqwest::ClientBuilder> {
|
|
builder = builder.no_proxy();
|
|
if !proxy.is_empty() && proxy != "-" {
|
|
builder = builder
|
|
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("Invalid proxy `{proxy}`"))?);
|
|
};
|
|
Ok(builder)
|
|
}
|
|
|
|
pub fn decode_bin<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T> {
|
|
let (v, _) = bincode::serde::decode_from_slice(data, bincode::config::legacy())?;
|
|
Ok(v)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn test_safe_join_path() {
|
|
assert_eq!(
|
|
safe_join_path("/home/user/dir1", "files/file1"),
|
|
Some(PathBuf::from("/home/user/dir1/files/file1"))
|
|
);
|
|
assert!(safe_join_path("/home/user/dir1", "/files/file1").is_none());
|
|
assert!(safe_join_path("/home/user/dir1", "../file1").is_none());
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(target_os = "windows")]
|
|
fn test_safe_join_path() {
|
|
assert_eq!(
|
|
safe_join_path("C:\\Users\\user\\dir1", "files/file1"),
|
|
Some(PathBuf::from("C:\\Users\\user\\dir1\\files\\file1"))
|
|
);
|
|
assert!(safe_join_path("C:\\Users\\user\\dir1", "/files/file1").is_none());
|
|
assert!(safe_join_path("C:\\Users\\user\\dir1", "../file1").is_none());
|
|
}
|
|
}
|