pub fn ensure_url_scheme(url: &str) -> String {
if !url.contains("://") {
format!("https://{}", url)
} else {
url.to_string()
}
}
pub fn expand_link_handler(command: &str, url: &str) -> Result<Vec<String>, String> {
let tokens = shell_words::split(command)
.map_err(|e| format!("Failed to parse link handler command: {}", e))?;
if tokens.is_empty() {
return Err("Link handler command is empty after expansion".to_string());
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| token.replace("{url}", url))
.collect();
Ok(parts)
}
pub fn open_url(url: &str, link_handler_command: &str) -> Result<(), String> {
let url_with_scheme = ensure_url_scheme(url);
if link_handler_command.is_empty() {
open::that(&url_with_scheme).map_err(|e| format!("Failed to open URL: {}", e))
} else {
let parts = expand_link_handler(link_handler_command, &url_with_scheme)?;
std::process::Command::new(&parts[0])
.args(&parts[1..])
.spawn()
.map(|_| ())
.map_err(|e| format!("Failed to run link handler '{}': {}", parts[0], e))
}
}
pub fn open_file_in_editor(
path: &str,
line: Option<usize>,
column: Option<usize>,
editor_mode: crate::config::SemanticHistoryEditorMode,
editor_cmd: &str,
cwd: Option<&str>,
) -> Result<(), String> {
let resolved_path = if path.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
path.replacen("~", &home.to_string_lossy(), 1)
} else {
path.to_string()
}
} else {
path.to_string()
};
let resolved_path = if resolved_path.starts_with("./") || resolved_path.starts_with("../") {
if let Some(working_dir) = cwd {
let expanded_cwd = if working_dir.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
working_dir.replacen("~", &home.to_string_lossy(), 1)
} else {
working_dir.to_string()
}
} else {
working_dir.to_string()
};
let cwd_path = std::path::Path::new(&expanded_cwd);
let full_path = cwd_path.join(&resolved_path);
crate::debug_info!(
"SEMANTIC",
"Resolved relative path: {:?} + {:?} = {:?}",
expanded_cwd,
resolved_path,
full_path
);
full_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| full_path.to_string_lossy().to_string())
} else {
resolved_path.clone()
}
} else {
resolved_path.clone()
};
let path_obj = std::path::Path::new(&resolved_path);
if !path_obj.exists() {
return Err(format!("Path not found: {}", resolved_path));
}
if path_obj.is_dir() {
crate::debug_info!(
"SEMANTIC",
"Opening directory in file manager: {}",
resolved_path
);
return open::that(&resolved_path).map_err(|e| format!("Failed to open directory: {}", e));
}
use crate::config::SemanticHistoryEditorMode;
let cmd = match editor_mode {
SemanticHistoryEditorMode::Custom => {
if editor_cmd.is_empty() {
crate::debug_info!(
"SEMANTIC",
"Custom mode but no editor configured, using system default for: {}",
resolved_path
);
return open::that(&resolved_path)
.map_err(|e| format!("Failed to open file: {}", e));
}
crate::debug_info!("SEMANTIC", "Using custom editor: {:?}", editor_cmd);
editor_cmd.to_string()
}
SemanticHistoryEditorMode::EnvironmentVariable => {
let env_editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.ok();
crate::debug_info!(
"SEMANTIC",
"Environment variable mode: EDITOR={:?}, VISUAL={:?}",
std::env::var("EDITOR").ok(),
std::env::var("VISUAL").ok()
);
if let Some(editor) = env_editor {
editor
} else {
crate::debug_info!(
"SEMANTIC",
"No $EDITOR/$VISUAL set, using system default for: {}",
resolved_path
);
return open::that(&resolved_path)
.map_err(|e| format!("Failed to open file: {}", e));
}
}
SemanticHistoryEditorMode::SystemDefault => {
crate::debug_info!(
"SEMANTIC",
"System default mode, opening with default app: {}",
resolved_path
);
return open::that(&resolved_path).map_err(|e| format!("Failed to open file: {}", e));
}
};
let line_str = line
.map(|l| l.to_string())
.unwrap_or_else(|| "1".to_string());
let col_str = column
.map(|c| c.to_string())
.unwrap_or_else(|| "1".to_string());
fn has_shell_metacharacters(template: &str) -> bool {
let stripped = template
.replace("{file}", "")
.replace("{line}", "")
.replace("{col}", "");
stripped.chars().any(|c| {
matches!(
c,
'|' | '&' | ';' | '$' | '`' | '(' | ')' | '{' | '}' | '>' | '<' | '~' | '\\' | '\''
)
})
}
let can_direct_spawn = cmd.contains("{file}") && !has_shell_metacharacters(&cmd);
crate::debug_info!(
"SEMANTIC",
"Executing editor command: {:?} for file: {} (line: {:?}, col: {:?}) direct_spawn={}",
cmd,
resolved_path,
line,
column,
can_direct_spawn
);
if can_direct_spawn {
let tokens = shell_words::split(&cmd)
.map_err(|e| format!("Failed to parse editor command: {}", e))?;
if tokens.is_empty() {
return Err("Editor command is empty".to_string());
}
let args: Vec<String> = tokens
.into_iter()
.map(|t| {
t.replace("{file}", &resolved_path)
.replace("{line}", &line_str)
.replace("{col}", &col_str)
})
.collect();
crate::debug_info!("SEMANTIC", "Direct spawn: {:?}", args);
std::process::Command::new(&args[0])
.args(&args[1..])
.spawn()
.map_err(|e| format!("Failed to launch editor '{}': {}", args[0], e))?;
} else {
let escaped_path = shell_escape(&resolved_path);
let escaped_line = shell_escape(&line_str);
let escaped_col = shell_escape(&col_str);
let full_cmd = cmd
.replace("{file}", &escaped_path)
.replace("{line}", &escaped_line)
.replace("{col}", &escaped_col);
let full_cmd = if !cmd.contains("{file}") {
format!("{} {}", full_cmd, escaped_path)
} else {
full_cmd
};
crate::debug_info!("SEMANTIC", "Shell spawn: {:?}", full_cmd);
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", &full_cmd])
.spawn()
.map_err(|e| format!("Failed to launch editor: {}", e))?;
}
#[cfg(not(target_os = "windows"))]
{
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
std::process::Command::new(&shell)
.args(["-lc", &full_cmd])
.spawn()
.map_err(|e| format!("Failed to launch editor with {}: {}", shell, e))?;
}
}
Ok(())
}
pub fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}