use hande_api::{Api, Configurations};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Cmd, Config, Context, Editor, Helper, KeyEvent, Modifiers};
use std::cell::Cell;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::process::Command;
const LICENSE: &str = r#"MIT License
Copyright (c) 2026 Ishe Chinyoka, the Textsmith
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE."#;
struct Completions {
local_names: Vec<String>,
global_names: Vec<String>,
keys: Vec<(char, String)>,
category_names: Vec<String>,
subcategory_children: Vec<String>,
}
impl Completions {
fn refresh(category: &str) -> Self {
let mut local_names = fetch_names(&["--category", category, "--list"]);
local_names.retain(|n| n != "(no bookmarks)");
let mut global_names = fetch_names(&["--list-global"]);
global_names.retain(|n| n != "(no bookmarks)");
let mut keys = parse_keys(&fetch_lines(&[
"--category",
category,
"--list-category-keys",
]));
keys.extend(parse_keys(&fetch_lines(&["--list-global-keys"])));
let category_names = Api::categories();
let (top, parent_path) = split_top(category);
let subcategory_children = Api::subcategory_children(top, &parent_path)
.into_iter()
.map(|name| format!(".{name}"))
.collect();
Self {
local_names,
global_names,
keys,
category_names,
subcategory_children,
}
}
fn to_candidates(&self, prefix: &str) -> Vec<String> {
let mut names = Self::name_candidates(&self.category_names, prefix);
names.extend(Self::name_candidates(&self.subcategory_children, prefix));
names
}
fn open_candidates(&self, prefix: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if prefix.chars().count() == 1 {
let c = prefix.chars().next().unwrap();
for (key, name) in &self.keys {
if *key == c && !out.contains(name) {
out.push(name.clone());
}
}
}
for name in self.local_names.iter().chain(self.global_names.iter()) {
if name.starts_with(prefix) && !out.contains(name) {
out.push(name.clone());
}
}
out
}
fn name_candidates(names: &[String], prefix: &str) -> Vec<String> {
names
.iter()
.filter(|n| n.starts_with(prefix))
.cloned()
.collect()
}
}
fn parse_keys(lines: &[String]) -> Vec<(char, String)> {
lines
.iter()
.filter_map(|line| {
let (key, name) = line.split_once(':')?;
let key = key.trim();
let name = name.trim();
if name.is_empty() {
return None;
}
let mut chars = key.chars();
let only_char = chars.next()?;
if chars.next().is_some() {
return None;
}
Some((only_char, name.to_string()))
})
.collect()
}
fn fetch_lines(args: &[&str]) -> Vec<String> {
let Ok(exe) = std::env::current_exe() else {
return Vec::new();
};
match Command::new(exe).args(args).output() {
Ok(output) => String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect(),
Err(_) => Vec::new(),
}
}
fn fetch_names(args: &[&str]) -> Vec<String> {
fetch_lines(args)
.into_iter()
.map(|line| line.split('\t').next().unwrap_or(&line).to_string())
.collect()
}
fn strip_any<'a>(line: &'a str, prefixes: &[&str]) -> Option<&'a str> {
prefixes.iter().find_map(|p| line.strip_prefix(p))
}
fn qualify(current_category: &str, reference: &str) -> String {
if let Some(rest) = reference.strip_prefix('.') {
format!("{current_category}.{rest}")
} else if reference.starts_with('/') {
format!("{current_category}.{reference}")
} else if !reference.contains('.')
&& reference
.chars()
.next()
.map(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
.unwrap_or(false)
{
format!("{current_category}.{reference}")
} else {
reference.to_string()
}
}
fn split_reference(qualified: &str) -> (Option<String>, String) {
match qualified.find('.') {
Some(pos) => {
let (category, name) = qualified.split_at(pos);
(Some(category.to_string()), name[1..].to_string())
}
None => (None, qualified.to_string()),
}
}
fn remove_line(content: &str, target: &str) -> String {
let mut removed = false;
let kept: Vec<&str> = content
.lines()
.filter(|line| {
if removed || *line != target {
true
} else {
removed = true;
false
}
})
.collect();
format!("{}\n", kept.join("\n"))
}
fn replace_line_in_place(content: &str, old_line: &str, new_line: &str) -> String {
let mut replaced = false;
let result: Vec<String> = content
.lines()
.map(|line| {
if !replaced && line == old_line {
replaced = true;
new_line.to_string()
} else {
line.to_string()
}
})
.collect();
format!("{}\n", result.join("\n"))
}
struct HandeHelper {
completions: Completions,
picking_category: Cell<bool>,
picking_bookmark: Cell<bool>,
}
impl Completer for HandeHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let typed = &line[..pos];
let as_pairs = |names: Vec<String>| -> Vec<Pair> {
names
.into_iter()
.map(|name| Pair {
display: name.clone(),
replacement: name,
})
.collect()
};
if self.picking_category.get() {
let names = self.completions.to_candidates(typed);
return Ok((0, as_pairs(names)));
}
if self.picking_bookmark.get() {
let mut names = Completions::name_candidates(&self.completions.local_names, typed);
names.extend(Completions::name_candidates(
&self.completions.global_names,
typed,
));
return Ok((0, as_pairs(names)));
}
if let Some(rest) = strip_any(typed, &[":d ", ":delete ", ":delete-bookmark "]) {
let start = pos - rest.len();
let names = Completions::name_candidates(&self.completions.local_names, rest);
return Ok((start, as_pairs(names)));
}
if let Some(rest) = strip_any(
typed,
&[":D ", ":delete-global ", ":delete-global-bookmark "],
) {
let start = pos - rest.len();
let names = Completions::name_candidates(&self.completions.global_names, rest);
return Ok((start, as_pairs(names)));
}
if let Some(rest) = strip_any(typed, &[":to ", ":switch "]) {
let start = pos - rest.len();
let names = self.completions.to_candidates(rest);
return Ok((start, as_pairs(names)));
}
if let Some(rest) = strip_any(typed, &[":m ", ":move ", ":y ", ":copy "]) {
return Ok(match rest.rsplit_once(' ') {
None => {
let start = pos - rest.len();
let mut names = Completions::name_candidates(&self.completions.local_names, rest);
names.extend(Completions::name_candidates(
&self.completions.global_names,
rest,
));
(start, as_pairs(names))
}
Some((_, second)) => {
let start = pos - second.len();
let mut names =
Completions::name_candidates(&self.completions.category_names, second);
if "_".starts_with(second) {
names.push("_".to_string());
}
(start, as_pairs(names))
}
});
}
if typed.starts_with(':') {
return Ok((0, Vec::new()));
}
if typed.is_empty() || typed.contains(char::is_whitespace) {
return Ok((0, Vec::new()));
}
Ok((0, as_pairs(self.completions.open_candidates(typed))))
}
}
impl Hinter for HandeHelper {
type Hint = String;
}
impl Highlighter for HandeHelper {}
impl Validator for HandeHelper {}
impl Helper for HandeHelper {}
fn history_path() -> String {
format!("{}/history", Configurations::get_app_home())
}
pub fn run() {
Api::ensure_initialized();
println!("Hande, Let's Go! version 1.0 (2026-10-01) -- \"Your Digital Treasures\"");
println!("Copyright (C) 2026 Ishe Chinyoka, the Textsmith (hande@thetextsmith.com) under the MIT license.");
println!();
println!("Type :help for help, :license for license.");
let config = Config::builder()
.auto_add_history(true)
.keyseq_timeout(Some(50))
.max_history_size(Api::history_size())
.map(|builder| builder.build())
.unwrap_or_default();
let mut editor: Editor<HandeHelper, _> = match Editor::with_config(config) {
Ok(editor) => editor,
Err(err) => {
eprintln!("Could not start the shell's line editor: {err}");
return;
}
};
editor.bind_sequence(KeyEvent::ctrl('g'), Cmd::Interrupt);
editor.bind_sequence(KeyEvent::new('\x1b', Modifiers::NONE), Cmd::Interrupt);
let mut current_category = Api::default_category();
editor.set_helper(Some(HandeHelper {
completions: Completions::refresh(¤t_category),
picking_category: Cell::new(false),
picking_bookmark: Cell::new(false),
}));
let history_file = history_path();
let _ = editor.load_history(&history_file);
loop {
let default_category = Api::default_category();
let prompt = if current_category == default_category {
"hlg> ".to_string()
} else {
format!("hlg ({current_category})> ")
};
let line = match editor.readline(&prompt) {
Ok(line) => line,
Err(ReadlineError::Interrupted) => continue, Err(ReadlineError::Eof) => break, Err(err) => {
eprintln!("Readline error: {err}");
break;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(command) = line.strip_prefix(':') {
if !handle_command(command.trim(), &mut editor, &mut current_category) {
break;
}
continue;
}
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() == 1 {
if let Some(chain) = words[0].strip_prefix("@_") {
match Api::resolve_underscore_target(¤t_category, chain) {
Ok((category, key)) => run_hande(&["--category", &category, &key]),
Err(msg) => eprintln!("{msg}"),
}
} else {
let qualified = qualify(¤t_category, words[0]);
run_hande(&[&qualified]);
}
} else {
run_hande(&words);
}
}
let _ = fs::create_dir_all(Configurations::get_app_home());
let _ = editor.save_history(&history_file);
}
fn handle_command(
command: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
current_category: &mut String,
) -> bool {
let (head, rest) = match command.split_once(' ') {
Some((h, r)) => (h, r.trim()),
None => (command, ""),
};
match head {
"quit" | "q" => return false,
"help" | "h" | "?" => print_help(),
"license" => println!("{LICENSE}"),
"list" | "l" => {
run_hande(&["--category", current_category, "--list"]);
run_hande(&["--list-global"]);
}
"category-list" | "c" => run_hande(&["--category", current_category, "--list"]),
"global-list" | "g" => run_hande(&["--list-global"]),
"to" | "switch" => {
if rest.is_empty() {
*current_category = Api::default_category();
println!("Switched to the '{current_category}' category.");
refresh_completions(editor, current_category);
} else if let Some(key_str) = rest.strip_prefix('@') {
match key_str.chars().next().filter(|_| key_str.chars().count() == 1) {
Some(key) => match Api::category_by_key(key) {
Some(name) => {
*current_category = name.clone();
println!("Switched to the '{name}' category.");
refresh_completions(editor, current_category);
}
None => eprintln!("No category found for shortcut '@{key}'."),
},
None => eprintln!("A category shortcut is a single character, like '@b'."),
}
} else if switch_to(current_category, rest) {
refresh_completions(editor, current_category);
}
}
"to?" | "switch?" => {
if let Some(name) = prompt_for_category(current_category, editor) {
*current_category = name.clone();
println!("Switched to the '{name}' category.");
refresh_completions(editor, current_category);
} else {
println!("Cancelled.");
}
}
"add-category" | "n" => {
add_category();
refresh_completions(editor, current_category);
}
"add-bookmark" | "a" | "ab" => {
add_bookmark(current_category);
refresh_completions(editor, current_category);
}
"update" | "u" => {
update_bookmark(current_category, editor);
refresh_completions(editor, current_category);
}
"add-key" | "ak" => {
add_key(current_category, editor);
refresh_completions(editor, current_category);
}
"add-handler" | "ah" => {
add_handler(current_category, editor);
}
"add-comment" | "ac" => {
add_comment(current_category, editor);
}
"add-alias" | "add-tag" | "aa" | "at" => {
add_alias(current_category, editor);
}
"delete" | "d" | "delete-bookmark" => {
if rest.is_empty() {
println!("Usage: :d <name> (or just :d and Tab to see candidates)");
} else {
run_hande(&["--category", current_category, "--delete-bookmark", rest]);
refresh_completions(editor, current_category);
}
}
"delete-global" | "D" | "delete-global-bookmark" => {
if rest.is_empty() {
println!("Usage: :D <name> (or just :D and Tab to see candidates)");
} else {
run_hande(&["--delete-global-bookmark", rest]);
refresh_completions(editor, current_category);
}
}
"move" | "m" => {
move_or_copy_bookmark(current_category, rest, true);
refresh_completions(editor, current_category);
}
"copy" | "y" => {
move_or_copy_bookmark(current_category, rest, false);
refresh_completions(editor, current_category);
}
"edit" | "e" => {
let (top, subpath) = split_top(current_category);
if subpath.is_empty() {
run_hande(&["--category", top, "--edit-bookmarks"]);
} else {
edit_subcategory(top, &subpath);
}
refresh_completions(editor, current_category);
}
"man" => run_hande(&["$manual$"]),
"config" | "cfg" => run_hande(&["--edit-configuration"]),
"import" | "i" => {
import_bookmarks(current_category, editor);
}
"export" | "x" => {
export_bookmarks(current_category, editor);
}
"" => {}
"@" => {
*current_category = Api::default_category();
println!("Switched to the '{current_category}' category.");
refresh_completions(editor, current_category);
}
other if other.starts_with('@') && other.len() > 1 => {
let chain = &other[1..];
match Api::resolve_shortcut_chain(chain, current_category) {
Ok(resolved) => {
*current_category = resolved.clone();
println!("Switched to the '{resolved}' category.");
refresh_completions(editor, current_category);
}
Err(msg) => eprintln!("{msg}"),
}
}
other => {
eprintln!("Unrecognized shell command ':{other}'. Type :help for a list.");
}
}
true
}
fn split_top(scope: &str) -> (&str, String) {
match scope.split_once('/') {
Some((top, rest)) => (top, rest.to_string()),
None => (scope, String::new()),
}
}
fn resolve_to_target(current: &str, target: &str) -> Result<String, String> {
if target == ".." {
return match current.rfind('/') {
Some(pos) => Ok(current[..pos].to_string()),
None => Err(format!(
"'{current}' has no parent subcategory; you're already at the top."
)),
};
}
if let Some(name) = target.strip_prefix('.') {
if name.is_empty() || name.contains('.') {
return Err(
"A relative subcategory name looks like '.name', with no further dots.".to_string(),
);
}
let candidate = format!("{current}/{name}");
let (top, path) = split_top(&candidate);
if !Api::categories().iter().any(|c| c == top) {
return Err(format!("No category named '{top}'."));
}
if !Api::subcategory_exists(top, &path) {
return Err(format!("No subcategory named '{name}' here."));
}
Ok(candidate)
} else if target.contains('.') {
let segments: Vec<&str> = target.split('.').collect();
let top = segments[0];
if !Api::categories().iter().any(|c| c == top) {
return Err(format!("No category named '{top}'."));
}
let path = segments[1..].join("/");
if !path.is_empty() && !Api::subcategory_exists(top, &path) {
return Err(format!(
"No subcategory named '{}' in '{top}'.",
segments[1..].join(".")
));
}
Ok(if path.is_empty() {
top.to_string()
} else {
format!("{top}/{path}")
})
} else if Api::categories().iter().any(|c| c == target) {
Ok(target.to_string())
} else {
Err(format!(
"No category named '{target}'. Type :to? to see the list."
))
}
}
fn switch_to(current_category: &mut String, target: &str) -> bool {
match resolve_to_target(current_category, target) {
Ok(resolved) => {
*current_category = resolved.clone();
println!("Switched to the '{resolved}' category.");
true
}
Err(msg) => {
eprintln!("{msg}");
false
}
}
}
fn prompt_for_category(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) -> Option<String> {
if let Some(helper) = editor.helper() {
helper.picking_category.set(true);
}
let result = editor.readline("Switch to which category: ");
if let Some(helper) = editor.helper() {
helper.picking_category.set(false);
}
let name = match result {
Ok(line) => line.trim().to_string(),
Err(_) => return None,
};
if name.is_empty() {
return None;
}
match resolve_to_target(current_category, &name) {
Ok(resolved) => Some(resolved),
Err(msg) => {
eprintln!("{msg}");
None
}
}
}
fn pick_bookmark(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) -> Option<(String, String, String, Option<String>)> {
if let Some(helper) = editor.helper() {
helper.picking_bookmark.set(true);
}
let result = editor.readline("Which bookmark? ");
if let Some(helper) = editor.helper() {
helper.picking_bookmark.set(false);
}
let reference = match result {
Ok(line) => line.trim().to_string(),
Err(_) => return None,
};
if reference.is_empty() {
return None;
}
let qualified = qualify(current_category, &reference);
let (hint, name) = split_reference(&qualified);
match Api::locate_bookmark_context(hint.as_deref(), &name) {
Some((category, line, comment)) => Some((category, name, line, comment)),
None => {
if let Some(full_name) = resolve_unambiguous_prefix(editor, &name) {
if let Some((category, line, comment)) =
Api::locate_bookmark_context(hint.as_deref(), &full_name)
{
return Some((category, full_name, line, comment));
}
}
eprintln!("No bookmark named '{name}' was found.");
None
}
}
}
fn resolve_unambiguous_prefix(
editor: &Editor<HandeHelper, impl rustyline::history::History>,
prefix: &str,
) -> Option<String> {
let helper = editor.helper()?;
let mut matches: Vec<&String> = helper
.completions
.local_names
.iter()
.chain(helper.completions.global_names.iter())
.filter(|n| n.starts_with(prefix))
.collect();
matches.dedup();
if matches.len() == 1 {
Some(matches[0].clone())
} else {
None
}
}
fn confirm(prompt: &str) -> bool {
print!("{prompt}");
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).unwrap_or(0) == 0 {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
fn read_value(prompt: &str) -> Option<String> {
print!("{prompt}");
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).unwrap_or(0) == 0 {
return None;
}
Some(input.trim().to_string())
}
fn bare_subcategory_name(raw: &str) -> String {
let raw = raw.trim();
raw.strip_suffix(':').unwrap_or(raw).trim().to_string()
}
fn find_subcategory_block(lines: &[&str], path: &str) -> Option<(usize, usize)> {
let segments: Vec<String> = path.split('/').map(str::to_string).collect();
let mut stack: Vec<String> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if let Some(name) = trimmed.strip_suffix('{') {
stack.push(bare_subcategory_name(name));
if stack == segments {
let mut depth = 1;
for (j, later) in lines.iter().enumerate().skip(i + 1) {
let t = later.trim();
if t.ends_with('{') {
depth += 1;
} else if t == "}" {
depth -= 1;
if depth == 0 {
return Some((i, j));
}
}
}
return None;
}
} else if trimmed == "}" {
stack.pop();
}
}
None
}
fn edit_subcategory(top: &str, subpath: &str) {
let app_home = Configurations::get_app_home();
let category_file = format!("{app_home}/bookmarks/{top}");
let Ok(original) = fs::read_to_string(&category_file) else {
eprintln!("Could not read the '{top}' category file.");
return;
};
let lines: Vec<&str> = original.lines().collect();
let Some((open_idx, close_idx)) = find_subcategory_block(&lines, subpath) else {
eprintln!("Could not find the '{subpath}' subcategory in '{top}'.");
return;
};
let inner = format!("{}\n", lines[open_idx + 1..close_idx].join("\n"));
let temp_path =
std::env::temp_dir().join(format!("hande-subcat-edit-{}.txt", std::process::id()));
if fs::write(&temp_path, &inner).is_err() {
eprintln!("Could not create a temporary file to edit.");
return;
}
let editor_program = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let status = Command::new(&editor_program).arg(&temp_path).status();
if !matches!(status, Ok(s) if s.success()) {
eprintln!("Editor exited without success -- nothing was changed.");
let _ = fs::remove_file(&temp_path);
return;
}
let Ok(edited) = fs::read_to_string(&temp_path) else {
eprintln!("Could not read back the edited content.");
let _ = fs::remove_file(&temp_path);
return;
};
let _ = fs::remove_file(&temp_path);
let mut new_lines: Vec<String> = lines[..=open_idx].iter().map(|s| s.to_string()).collect();
new_lines.extend(edited.lines().map(str::to_string));
new_lines.extend(lines[close_idx..].iter().map(|s| s.to_string()));
let candidate = format!("{}\n", new_lines.join("\n"));
commit_with_validation(
&app_home,
top,
&candidate,
&format!("Updated the '{subpath}' subcategory."),
"changed",
);
}
fn run_validation_check(exe: &std::path::Path, sandbox: &std::path::Path, args: &[&str]) -> bool {
match Command::new(exe).env("HANDE_HOME", sandbox).args(args).output() {
Ok(output) => {
if !output.status.success() {
let _ = io::stderr().write_all(&output.stderr);
}
output.status.success()
}
Err(_) => false,
}
}
fn commit_with_validation(app_home: &str, category: &str, candidate: &str, success: &str, verb: &str) {
let overrides = [(category.to_string(), candidate.to_string())];
let sandbox = std::env::temp_dir().join(format!("hande-validate-{}", std::process::id()));
if let Err(err) = build_sandbox(app_home, &sandbox, &overrides) {
eprintln!("Could not prepare a sandbox to check this: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
let exe = match std::env::current_exe() {
Ok(path) => path,
Err(err) => {
eprintln!("Could not locate the hande binary: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
};
let destination_ok = run_validation_check(&exe, &sandbox, &["--category", category, "--list"]);
let global_ok = run_validation_check(&exe, &sandbox, &["--list-global"]);
if destination_ok && global_ok {
match fs::write(format!("{app_home}/bookmarks/{category}"), candidate) {
Ok(_) => println!("{success}"),
Err(err) => eprintln!("It checked out fine, but could not be saved: {err}"),
}
} else {
println!("Not {verb} -- see the error above, fix it, and try again.");
}
let _ = fs::remove_dir_all(&sandbox);
}
fn update_bookmark(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some((category, _name, line, _comment)) = pick_bookmark(current_category, editor) else {
return;
};
let new_line = match editor.readline_with_initial("New line: ", (&line, "")) {
Ok(l) => l.trim().to_string(),
Err(_) => {
println!("Cancelled.");
return;
}
};
if new_line.is_empty() {
println!("Cancelled: an empty line would delete the bookmark -- use :delete for that.");
return;
}
if new_line == line {
println!("No change.");
return;
}
let (top, _) = split_top(&category);
let app_home = Configurations::get_app_home();
let original = fs::read_to_string(format!("{app_home}/bookmarks/{top}")).unwrap_or_default();
let candidate = replace_line_in_place(&original, &line, &new_line);
commit_with_validation(
&app_home,
top,
&candidate,
&format!("Updated the bookmark in the '{category}' category."),
"updated",
);
}
fn extract_key(line: &str) -> Option<char> {
let field = line.find('<').map(|p| line[..p].trim()).unwrap_or("");
if let Some(rest) = field.strip_prefix('_') {
rest.chars().next()
} else if let (Some(open), Some(close)) = (field.find('('), field.find(')')) {
if close > open + 1 {
field[open + 1..close].chars().next()
} else {
None
}
} else {
None
}
}
fn set_key_field(field: &str, key: char) -> String {
let base = if let Some(rest) = field.strip_prefix('_') {
rest.to_string()
} else if let Some(open) = field.find('(') {
field[..open].to_string()
} else {
field.to_string()
};
format!("{base}({key})")
}
fn add_key(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some((category, name, line, _comment)) = pick_bookmark(current_category, editor) else {
return;
};
if let Some(existing) = extract_key(&line) {
if !confirm(&format!(
"'{name}' already has the shortcut key '{existing}'. Overwrite? [y/N] "
)) {
println!("Cancelled.");
return;
}
}
let Some(input) = read_value(&format!("New shortcut key for '{name}': ")) else {
return;
};
let mut chars = input.chars();
let (Some(key), None) = (chars.next(), chars.next()) else {
println!("Cancelled: a shortcut key must be a single character.");
return;
};
let is_global = name
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false);
if is_global && !key.is_ascii_uppercase() {
eprintln!(
"Error: '{name}' is global, so its shortcut key must be uppercase -- try '{}'.",
key.to_ascii_uppercase()
);
return;
}
if !is_global && key.is_ascii_uppercase() {
eprintln!(
"Error: '{name}' is local, so its shortcut key can't be uppercase -- try '{}'.",
key.to_ascii_lowercase()
);
return;
}
let field = line.find('<').map(|p| line[..p].trim()).unwrap_or("");
let new_field = set_key_field(field, key);
let new_line = replace_name_field(&line, &new_field);
let (top, _) = split_top(&category);
let app_home = Configurations::get_app_home();
let original = fs::read_to_string(format!("{app_home}/bookmarks/{top}")).unwrap_or_default();
let candidate = replace_line_in_place(&original, &line, &new_line);
commit_with_validation(
&app_home,
top,
&candidate,
&format!("'{name}' now has the shortcut key '{key}'."),
"changed",
);
}
fn extract_handler(line: &str) -> Option<String> {
let after = line.rfind('>').map(|p| line[p + 1..].trim())?;
if after.is_empty() {
None
} else {
Some(after.to_string())
}
}
fn add_handler(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some((category, name, line, _comment)) = pick_bookmark(current_category, editor) else {
return;
};
if let Some(existing) = extract_handler(&line) {
if !confirm(&format!(
"'{name}' already has the handler '{existing}'. Overwrite? [y/N] "
)) {
println!("Cancelled.");
return;
}
}
let Some(handler) = read_value(&format!("New handler for '{name}' (e.g. chrome): ")) else {
return;
};
if handler.is_empty() {
println!("Cancelled: no handler given.");
return;
}
let handler = handler.strip_prefix(':').unwrap_or(&handler);
let Some(pos) = line.rfind('>') else {
eprintln!("'{name}' has no location field to attach a handler after.");
return;
};
let new_line = format!("{} :{handler}", &line[..=pos]);
let (top, _) = split_top(&category);
let app_home = Configurations::get_app_home();
let original = fs::read_to_string(format!("{app_home}/bookmarks/{top}")).unwrap_or_default();
let candidate = replace_line_in_place(&original, &line, &new_line);
commit_with_validation(
&app_home,
top,
&candidate,
&format!("'{name}' now uses the '{handler}' handler."),
"changed",
);
}
fn parse_comment_line(line: &str) -> (String, Vec<String>) {
let body = line.trim_start_matches('#').trim();
if let Some(pos) = body.find('[') {
let (desc, bracket) = body.split_at(pos);
let tags: Vec<String> = bracket
.trim_start_matches('[')
.trim_end_matches(']')
.split_whitespace()
.map(str::to_lowercase)
.collect();
(desc.trim().to_string(), tags)
} else {
(body.trim().to_string(), Vec::new())
}
}
fn build_comment_line(description: &str, tags: &[String]) -> String {
if tags.is_empty() {
format!("# {description}")
} else if description.is_empty() {
format!("# [{}]", tags.join(" "))
} else {
format!("# {description} [{}]", tags.join(" "))
}
}
fn apply_comment_change(
category: &str,
bookmark_line: &str,
old_comment: Option<&str>,
new_comment_line: &str,
success: &str,
) {
let (top, _) = split_top(category);
let app_home = Configurations::get_app_home();
let original = fs::read_to_string(format!("{app_home}/bookmarks/{top}")).unwrap_or_default();
let mut updated: Vec<String> = Vec::new();
for line in original.lines() {
let trimmed = line.trim();
if let Some(old) = old_comment {
if trimmed == old {
continue;
}
}
if trimmed == bookmark_line {
updated.push(new_comment_line.to_string());
}
updated.push(line.to_string());
}
let candidate = format!("{}\n", updated.join("\n"));
commit_with_validation(&app_home, top, &candidate, success, "changed");
}
fn add_comment(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some((category, name, line, comment)) = pick_bookmark(current_category, editor) else {
return;
};
let (existing_desc, existing_tags) = comment
.as_deref()
.map(parse_comment_line)
.unwrap_or_default();
if !existing_desc.is_empty()
&& !confirm(&format!(
"'{name}' already has the comment \"{existing_desc}\". Overwrite? [y/N] "
))
{
println!("Cancelled.");
return;
}
let Some(new_desc) = read_value(&format!("New comment for '{name}': ")) else {
return;
};
if new_desc.is_empty() {
println!("Cancelled: no comment given.");
return;
}
let new_comment_line = build_comment_line(&new_desc, &existing_tags);
apply_comment_change(
&category,
&line,
comment.as_deref(),
&new_comment_line,
&format!("'{name}' now has that comment."),
);
}
fn add_alias(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some((category, name, line, comment)) = pick_bookmark(current_category, editor) else {
return;
};
let (existing_desc, existing_tags) = comment
.as_deref()
.map(parse_comment_line)
.unwrap_or_default();
if !existing_tags.is_empty()
&& !confirm(&format!(
"'{name}' already has the tags [{}]. Overwrite? [y/N] ",
existing_tags.join(" ")
))
{
println!("Cancelled.");
return;
}
let Some(input) = read_value(&format!("New tags for '{name}' (space-separated): ")) else {
return;
};
let new_tags: Vec<String> = input.split_whitespace().map(str::to_lowercase).collect();
if new_tags.is_empty() {
println!("Cancelled: no tags given.");
return;
}
let new_comment_line = build_comment_line(&existing_desc, &new_tags);
apply_comment_change(
&category,
&line,
comment.as_deref(),
&new_comment_line,
&format!("'{name}' now has those tags."),
);
}
fn refresh_completions(
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
category: &str,
) {
if let Some(helper) = editor.helper_mut() {
helper.completions = Completions::refresh(category);
}
}
fn print_help() {
println!("Shell commands (all prefixed with ':'; short letter in brackets):");
println!(" :quit [:q] leave the shell");
println!(" :list [:l] list every bookmark, working category and global");
println!(" :category-list [:c] list bookmarks in the working category only");
println!(" :global-list [:g] list bookmarks in the global scope only");
println!(" :to <name> [:switch] switch the working category");
println!(" :to [:switch] switch back to the default category");
println!(" :to @<key> switch by a category's shortcut key");
println!(" :to? [:switch?] prompt for a category, with Tab completion");
println!(" :@<keys> jump straight to a category/subcategory by its shortcut key");
println!(" chain (e.g. :@we) -- every character is a category or");
println!(" subcategory key, unlike a bookmark's own '@' shortcut, whose");
println!(" last character always picks a bookmark instead; :@ alone");
println!(" resets to the default category, like :to alone does. Start");
println!(" the chain with '_' (e.g. :@_e, @_ec) to walk from the working");
println!(" category instead of a top-level one -- handy for a nearby");
println!(" subcategory you'd otherwise need its whole key chain to reach");
println!(" :add-category [:n] add a new category");
println!(" :add-bookmark [:a, :ab] add a bookmark to the working category");
println!(" :update [:u] pick a bookmark and retype its whole line freely");
println!(" (pre-filled with what's there now -- also how you rename)");
println!(" :add-key [:ak] pick a bookmark and give it a shortcut key");
println!(" :add-handler [:ah] pick a bookmark and give it a handler (e.g. chrome)");
println!(" :add-comment [:ac] pick a bookmark and give it a description");
println!(" :add-alias [:aa, :at, :add-tag] pick a bookmark and give it tags");
println!(" each add-* warns and asks before overwriting an existing");
println!(" key/handler/comment/tags");
println!(" :delete <name> [:d] delete a bookmark from the working category");
println!(" :delete-global <name> [:D] delete a global bookmark");
println!(" :move <bookmark> <category> [:m] move a bookmark into another category");
println!(" :copy <bookmark> <category> [:y] copy a local bookmark into another category");
println!(" a :move destination can also be '_' (working category,");
println!(" lowercasing the name if needed), ':' (global scope, keeping");
println!(" the name but capitalizing it), or a capitalized new name");
println!(" (global scope, renamed) -- relocating a bookmark's file");
println!(" never changes its scope on its own, since that's decided");
println!(" purely by the name's case, so these forms rename it to match");
println!(" :edit [:e] edit the working category's bookmarks in $EDITOR");
println!(" :help [:h] show this message");
println!(" :license show the MIT license");
println!(" :man open the full manual (a quick-start is under '?')");
println!(" :config [:cfg] edit the configuration file in $EDITOR");
println!(" :import [:i] import bookmarks from a browser's exported HTML file");
println!(" :export [:x] export bookmarks as an HTML file a browser can import");
println!();
println!("Anything else opens hande itself: a single word opens that bookmark,");
println!("shortcut, or alias in the working category (just like `hande --category");
println!("<name> <word>`); more than one word runs a search. Tab-complete bookmark");
println!("names, shortcuts, and the arguments to :delete/:delete-global/:to/:move/:copy;");
println!("use the up/down arrows for command history. A bookmark reference for :move");
println!("or :copy can be a bare name (working category), '.name', 'category.name',");
println!("or a bare capitalized (global) name.");
}
fn prompt_for_path(
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
prompt: &str,
) -> Option<String> {
match editor.readline_with_initial(prompt, ("bookmarks.html", "")) {
Ok(line) => {
let path = line.trim().to_string();
if path.is_empty() {
None
} else {
Some(path)
}
}
Err(_) => None,
}
}
fn import_bookmarks(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some(path) = prompt_for_path(editor, "Path to the browser bookmarks HTML file: ") else {
println!("Cancelled: no file given.");
return;
};
let prompt = format!("Import into which category? [{current_category}]: ");
let Some(category) = read_value(&prompt) else {
return;
};
let category = if category.is_empty() {
current_category.to_string()
} else {
category
};
if confirm(
"Preserve folder structure as nested subcategories, rather than importing flat? [y/N] ",
) {
run_hande(&[
"--import-bookmarks",
path.as_str(),
"--category",
category.as_str(),
"--preserve-folders",
]);
} else {
run_hande(&["--import-bookmarks", path.as_str(), "--category", category.as_str()]);
}
refresh_completions(editor, current_category);
}
fn export_bookmarks(
current_category: &str,
editor: &mut Editor<HandeHelper, impl rustyline::history::History>,
) {
let Some(path) = prompt_for_path(editor, "Path to write the exported HTML file: ") else {
println!("Cancelled: no file given.");
return;
};
if confirm(&format!(
"Export only the '{current_category}' category, rather than everything? [y/N] "
)) {
run_hande(&["--export-bookmarks", path.as_str(), "--category", current_category]);
} else {
run_hande(&["--export-bookmarks", path.as_str()]);
}
}
fn add_category() {
print!("New category name: ");
let _ = io::stdout().flush();
let mut name = String::new();
if io::stdin().read_line(&mut name).unwrap_or(0) == 0 {
return;
}
let name = name.trim().to_string();
if name.is_empty() {
println!("Cancelled: no category name given.");
return;
}
run_hande(&["--new-category", &name]);
if !confirm(&format!(
"Set up a custom header for '{name}' instead of the default one? [y/N] "
)) {
return;
}
let Some(url) = read_value(
"Web address for this category's header (a plain domain, or a full http(s):// address): ",
) else {
return;
};
if url.is_empty() {
println!("Cancelled: no address given, keeping the default header.");
return;
}
let key = read_value("Optional shortcut key for this category (Enter to skip): ")
.unwrap_or_default();
let description = read_value("Optional description for this category (Enter to skip): ")
.unwrap_or_default();
let location = if url.starts_with("http://") || url.starts_with("https://") || url.starts_with('@')
{
url
} else {
format!("@{url}")
};
let key_part = key.chars().next().map(|c| c.to_string()).unwrap_or_default();
let new_header = if description.is_empty() {
format!("*{key_part} <{location}>")
} else {
format!("*{key_part} <{location}> {description}")
};
let app_home = Configurations::get_app_home();
let category_file = format!("{app_home}/bookmarks/{name}");
let original = fs::read_to_string(&category_file).unwrap_or_default();
let mut lines: Vec<String> = original.lines().map(str::to_string).collect();
if lines.is_empty() {
eprintln!("Could not read the new category file back to give it a header.");
return;
}
lines[0] = new_header;
let candidate = format!("{}\n", lines.join("\n"));
commit_with_validation(
&app_home,
&name,
&candidate,
&format!("'{name}' now has its own header."),
"changed",
);
}
fn add_bookmark(category: &str) {
print!("New bookmark for the '{category}' category, e.g. `guardian <@guardian.co.uk> :chrome`: ");
let _ = io::stdout().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
return;
}
let line = line.trim();
if line.is_empty() {
println!("Cancelled: no bookmark entered.");
return;
}
if !line.contains('<') || !line.contains('>') {
println!("A bookmark needs a location in angle brackets, like `name <location>`.");
return;
}
let (top, subpath) = split_top(category);
let app_home = Configurations::get_app_home();
let category_file = format!("{app_home}/bookmarks/{top}");
let original = fs::read_to_string(&category_file).unwrap_or_default();
let candidate = if subpath.is_empty() {
format!("{}\n{line}\n", original.trim_end())
} else {
let file_lines: Vec<&str> = original.lines().collect();
match find_subcategory_block(&file_lines, &subpath) {
Some((_, close_idx)) => {
let mut new_lines: Vec<String> =
file_lines[..close_idx].iter().map(|s| s.to_string()).collect();
new_lines.push(line.to_string());
new_lines.extend(file_lines[close_idx..].iter().map(|s| s.to_string()));
format!("{}\n", new_lines.join("\n"))
}
None => {
eprintln!("Could not find the '{subpath}' subcategory in '{top}'.");
return;
}
}
};
let sandbox = std::env::temp_dir().join(format!("hande-validate-{}", std::process::id()));
let overrides = [(top.to_string(), candidate.clone())];
if let Err(err) = build_sandbox(&app_home, &sandbox, &overrides) {
eprintln!("Could not prepare a sandbox to check this bookmark: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
let exe = match std::env::current_exe() {
Ok(path) => path,
Err(err) => {
eprintln!("Could not locate the hande binary: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
};
let status = run_validation_check(&exe, &sandbox, &["--category", top, "--list"]);
if status {
match fs::write(&category_file, &candidate) {
Ok(_) => println!("Added to the '{category}' category."),
Err(err) => eprintln!("It checked out fine, but could not be saved: {err}"),
}
} else {
println!("Not added -- see the error above, fix it, and try again.");
}
let _ = fs::remove_dir_all(&sandbox);
}
enum Destination {
Local(String),
Global(Option<String>),
}
fn parse_destination(current_category: &str, dest: &str) -> Result<Destination, String> {
if dest == ":" {
return Ok(Destination::Global(None));
}
if dest == "_" {
return Ok(Destination::Local(current_category.to_string()));
}
if let Some(category) = dest.strip_suffix("._") {
if category.is_empty() {
return Err(format!("'{dest}' needs a category name before the dot."));
}
return Ok(Destination::Local(category.to_string()));
}
if dest.contains('.') {
return Err(format!(
"'{dest}' isn't a supported destination -- use '_', ':', a category name, or 'category._'."
));
}
if dest.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
return Ok(Destination::Global(Some(dest.to_string())));
}
Ok(Destination::Local(dest.to_string()))
}
fn lowercase_first(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn uppercase_first(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn rename_field(name_field: &str, is_now_global: bool) -> String {
let mut chars: Vec<char> = name_field.chars().collect();
if chars.is_empty() {
return name_field.to_string();
}
if chars[0] == '_' {
if chars.len() > 1 {
chars[1] = if is_now_global {
chars[1].to_ascii_uppercase()
} else {
chars[1].to_ascii_lowercase()
};
}
} else {
chars[0] = if is_now_global {
chars[0].to_ascii_uppercase()
} else {
chars[0].to_ascii_lowercase()
};
if let (Some(open), Some(close)) = (
chars.iter().position(|&c| c == '('),
chars.iter().position(|&c| c == ')'),
) {
if close > open + 1 {
for c in chars.iter_mut().take(close).skip(open + 1) {
*c = if is_now_global {
c.to_ascii_uppercase()
} else {
c.to_ascii_lowercase()
};
}
}
}
}
chars.into_iter().collect()
}
fn replace_name_field(line: &str, new_name_field: &str) -> String {
let Some(pos) = line.find('<') else {
return line.to_string();
};
let before = &line[..pos];
let trimmed = before.trim_end();
let gap = &before[trimmed.len()..];
format!("{new_name_field}{gap}{}", &line[pos..])
}
fn move_or_copy_bookmark(current_category: &str, args: &str, is_move: bool) {
let mut parts = args.split_whitespace();
let (Some(reference), Some(dest_arg), None) = (parts.next(), parts.next(), parts.next())
else {
let example = if is_move {
":m <bookmark> <category|_|:|Name>"
} else {
":y <bookmark> <category|_>"
};
println!("Usage: {example}");
return;
};
let qualified = qualify(current_category, reference);
let (category_hint, target_name) = split_reference(&qualified);
let Some((source_category, line)) = Api::locate_bookmark(category_hint.as_deref(), &target_name)
else {
eprintln!("No bookmark named '{target_name}' was found.");
return;
};
let source_is_global = target_name
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false);
if !is_move && source_is_global {
eprintln!(
"Error: '{target_name}' is a global bookmark, so it's already reachable from every category."
);
eprintln!(
"Copying it would leave two bookmarks with the same name, which hande won't allow. Did you mean :move?"
);
return;
}
let destination = match parse_destination(current_category, dest_arg) {
Ok(d) => d,
Err(msg) => {
eprintln!("Error: {msg}");
return;
}
};
if !is_move {
if let Destination::Global(_) = destination {
eprintln!("Error: :copy only duplicates a bookmark within local categories.");
eprintln!(
"A global copy would give it a second identity outside any one category -- that isn't really a copy. Add it directly if you want a new global bookmark, or use :move if you mean to relocate this one."
);
return;
}
}
let (destination_category, new_name, name_field) = match &destination {
Destination::Local(category) => {
if !Api::categories().iter().any(|c| c == category) {
eprintln!("No category named '{category}'.");
return;
}
let new_name = lowercase_first(&target_name);
let position = line.find('<').map(|p| line[..p].trim()).unwrap_or("");
let field = rename_field(position, false);
(category.clone(), new_name, field)
}
Destination::Global(None) => {
let new_name = uppercase_first(&target_name);
let position = line.find('<').map(|p| line[..p].trim()).unwrap_or("");
let field = rename_field(position, true);
(source_category.clone(), new_name, field)
}
Destination::Global(Some(explicit)) => {
(source_category.clone(), explicit.clone(), explicit.clone())
}
};
if source_category == destination_category && new_name == target_name {
println!("'{target_name}' is already in the '{destination_category}' category.");
return;
}
let new_line = replace_name_field(&line, &name_field);
let app_home = Configurations::get_app_home();
let mut affected: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for category in [&source_category, &destination_category] {
affected.entry(category.clone()).or_insert_with(|| {
fs::read_to_string(format!("{app_home}/bookmarks/{category}")).unwrap_or_default()
});
}
if source_category == destination_category {
let content = affected.get_mut(&source_category).unwrap();
*content = replace_line_in_place(content, &line, &new_line);
} else {
if is_move {
let content = affected.get_mut(&source_category).unwrap();
*content = remove_line(content, &line);
}
let content = affected.get_mut(&destination_category).unwrap();
*content = format!("{}\n{new_line}\n", content.trim_end());
}
let overrides: Vec<(String, String)> = affected.into_iter().collect();
let sandbox = std::env::temp_dir().join(format!("hande-validate-{}", std::process::id()));
if let Err(err) = build_sandbox(&app_home, &sandbox, &overrides) {
eprintln!("Could not prepare a sandbox to check this: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
let exe = match std::env::current_exe() {
Ok(path) => path,
Err(err) => {
eprintln!("Could not locate the hande binary: {err}");
let _ = fs::remove_dir_all(&sandbox);
return;
}
};
let destination_ok =
run_validation_check(&exe, &sandbox, &["--category", &destination_category, "--list"]);
let global_ok = run_validation_check(&exe, &sandbox, &["--list-global"]);
if destination_ok && global_ok {
let mut ok = true;
for (category, content) in &overrides {
ok &= fs::write(format!("{app_home}/bookmarks/{category}"), content).is_ok();
}
if ok {
let verb = if is_move { "Moved" } else { "Copied" };
let rename_note = if new_name != target_name {
format!(" as '{new_name}'")
} else {
String::new()
};
if source_category == destination_category {
println!(
"{verb} '{target_name}'{rename_note} within the '{source_category}' category."
);
} else {
println!(
"{verb} '{target_name}'{rename_note} from '{source_category}' to '{destination_category}'."
);
}
} else {
eprintln!("It checked out fine, but could not be saved.");
}
} else {
let verb = if is_move { "moved" } else { "copied" };
println!("Not {verb} -- see the error above, fix it, and try again.");
}
let _ = fs::remove_dir_all(&sandbox);
}
fn build_sandbox(app_home: &str, sandbox: &Path, overrides: &[(String, String)]) -> io::Result<()> {
fs::create_dir_all(sandbox.join("bookmarks"))?;
let config_src = format!("{app_home}/config.toml");
if Path::new(&config_src).exists() {
fs::copy(&config_src, sandbox.join("config.toml"))?;
}
let bookmarks_src = format!("{app_home}/bookmarks");
if let Ok(entries) = fs::read_dir(&bookmarks_src) {
for entry in entries.flatten() {
if entry.path().is_file() {
fs::copy(entry.path(), sandbox.join("bookmarks").join(entry.file_name()))?;
}
}
}
for (category, content) in overrides {
fs::write(sandbox.join("bookmarks").join(category), content)?;
}
Ok(())
}
fn run_hande(args: &[&str]) {
let exe = match std::env::current_exe() {
Ok(path) => path,
Err(err) => {
eprintln!("Could not locate the hande binary: {err}");
return;
}
};
unsafe {
libc::signal(libc::SIGINT, libc::SIG_IGN);
}
let result = Command::new(exe).args(args).status();
unsafe {
libc::signal(libc::SIGINT, libc::SIG_DFL);
}
match result {
Ok(_) => {}
Err(err) => eprintln!("Could not run hande: {err}"),
}
}