mod app;
mod bookmarks;
mod config;
mod dir_index;
mod disks;
mod event_handler;
mod navigation;
mod platform;
mod quick_jump;
mod search;
mod terminal;
mod theme;
mod tree_node;
mod ui;
use anyhow::Result;
use app::App;
use bookmarks::Bookmarks;
use clap::Parser;
use config::Config;
use platform::canonicalize_and_normalize;
use std::path::{Path, PathBuf};
use terminal::{cleanup_terminal_compact, run_app, setup_terminal_compact};
#[derive(Parser)]
#[command(name = "bmrk")]
#[command(about = "Interactive bookmark manager and directory navigator")]
#[command(disable_help_flag = true)]
#[command(disable_version_flag = true)]
struct Args {
#[arg(short = 'h', long = "help")]
help: bool,
#[arg(short = 'v', long = "version")]
version: bool,
#[arg(short = 'l', long = "list")]
list: bool,
#[arg(
short = 'a',
long = "add",
visible_short_alias = 'c',
value_name = "NAME"
)]
add: Option<String>,
#[arg(short = 'd', long = "del", value_name = "NAME")]
del: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
fn split_bookmark_prefix(input: &str) -> Option<(&str, &str)> {
#[cfg(windows)]
let sep: &[char] = &['/', '\\'];
#[cfg(not(windows))]
let sep: &[char] = &['/'];
let idx = input.find(sep)?;
let head = &input[..idx];
let tail = input[idx + 1..].trim_start_matches(sep);
Some((head, tail))
}
fn resolve_path_or_bookmark(input: &str, bookmarks: &Bookmarks, base: &Path) -> Result<PathBuf> {
#[cfg(windows)]
{
if input.len() == 2 && input.chars().nth(1) == Some(':') {
let drive_letter = input.chars().next().unwrap();
if drive_letter.is_ascii_alphabetic() {
let root_path = format!("{}\\", input);
let path = PathBuf::from(&root_path);
if path.exists() {
return Ok(canonicalize_and_normalize(&path)?);
} else {
anyhow::bail!("Drive not found: {}", input);
}
}
}
}
if platform::is_absolute_path(input) {
let path = base.join(input);
if !path.exists() {
anyhow::bail!("Directory not found: {}", input);
}
return Ok(canonicalize_and_normalize(&path)?);
}
if let Some((head, tail)) = split_bookmark_prefix(input) {
let literal = base.join(input);
if literal.exists() {
return Ok(canonicalize_and_normalize(&literal)?);
}
if let Some(bookmark) = bookmarks.get(head) {
if !bookmark.path.exists() {
anyhow::bail!(
"Bookmark '{}' points to non-existent directory: {}\n\
Use 'bm -l' to see all bookmarks",
head,
bookmark.path.display()
);
}
let target = if tail.is_empty() {
bookmark.path.clone()
} else {
bookmark.path.join(platform::normalize_path_separator(tail))
};
if !target.exists() {
anyhow::bail!(
"Bookmark '{}' has no entry '{}': {} does not exist",
head,
tail,
target.display()
);
}
return Ok(canonicalize_and_normalize(&target)?);
}
}
if let Some(bookmark) = bookmarks.get(input) {
if bookmark.path.exists() {
return Ok(bookmark.path.clone());
} else {
anyhow::bail!(
"Bookmark '{}' points to non-existent directory: {}\n\
Use 'bm -l' to see all bookmarks",
input,
bookmark.path.display()
);
}
}
let path = base.join(input);
if path.exists() {
return Ok(canonicalize_and_normalize(&path)?);
}
anyhow::bail!(
"Neither bookmark '{}' nor directory '{}' found.\n\
Use 'bm -l' to see all bookmarks",
input,
input
);
}
fn main() -> Result<()> {
let args = Args::parse();
if args.version {
println!("bmrk {}", env!("CARGO_PKG_VERSION"));
println!("{}", env!("CARGO_PKG_DESCRIPTION"));
println!();
println!(
"Platform: {} ({})",
std::env::consts::OS,
std::env::consts::ARCH
);
match Config::global_config_path() {
Some(path) => println!("Config: {}", path.display()),
None => println!("Config: (could not determine config directory)"),
}
match dirs::config_dir() {
Some(dir) => println!(
"Bookmarks: {}",
dir.join("bmrk").join("bookmarks.json").display()
),
None => println!("Bookmarks: (could not determine config directory)"),
}
println!("Homepage: {}", env!("CARGO_PKG_REPOSITORY"));
return Ok(());
}
if args.help {
for line in ui::get_help_content() {
println!("{}", line);
}
return Ok(());
}
if args.list {
let bookmarks = Bookmarks::new()?;
println!("Bookmarks:");
if bookmarks.list().is_empty() {
println!(" No bookmarks saved yet.");
println!("\nUsage:");
println!(" bm -a <name> [path] Add a bookmark (alias: -c)");
println!(" bm -d <name> Remove a bookmark");
println!(" bm -l List all bookmarks");
} else {
for bookmark in bookmarks.list() {
let name = bookmark.name.as_deref().unwrap_or("(unnamed)");
println!(
" {} -> {} ({})",
bookmark.key,
name,
bookmark.path.display()
);
}
}
return Ok(());
}
if let Some(name) = args.add {
let mut bookmarks = Bookmarks::new()?;
let path = if !args.args.is_empty() {
PathBuf::from(&args.args[0])
} else {
std::env::current_dir()?
};
if !path.exists() {
anyhow::bail!("Path does not exist: {}", path.display());
}
let mut path = canonicalize_and_normalize(&path)?;
if path.is_file() {
if let Some(parent) = path.parent() {
path = parent.to_path_buf();
eprintln!("Note: File provided, using parent directory instead");
} else {
anyhow::bail!("Cannot determine parent directory");
}
}
let dir_name = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
bookmarks.add(name.clone(), path.clone(), dir_name)?;
println!("Bookmark '{}' added: {}", name, path.display());
return Ok(());
}
if let Some(name) = args.del {
let mut bookmarks = Bookmarks::new()?;
bookmarks.remove(&name)?;
println!("Bookmark '{}' removed", name);
return Ok(());
}
if !args.args.is_empty() {
let bookmarks = Bookmarks::new()?;
let cwd = std::env::current_dir()?;
let resolved = resolve_path_or_bookmark(&args.args[0], &bookmarks, &cwd)?;
println!("{}", resolved.display());
return Ok(());
}
let start_path = std::env::current_dir()?;
let mut app = App::new(start_path)?;
app.start_background_index();
let result = {
let mut terminal = setup_terminal_compact()?;
run_app(&mut terminal, &mut app)
};
cleanup_terminal_compact()?;
if let Some(path) = result? {
println!("{}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn bookmarks_in(dir: &TempDir) -> Bookmarks {
Bookmarks::new_in_memory(dir.path().join("bookmarks.json"))
}
#[test]
fn split_bookmark_prefix_splits_on_first_separator() {
assert_eq!(
split_bookmark_prefix("work/sub/dir"),
Some(("work", "sub/dir"))
);
assert_eq!(split_bookmark_prefix("work/"), Some(("work", "")));
assert_eq!(split_bookmark_prefix("work//sub"), Some(("work", "sub")));
assert_eq!(split_bookmark_prefix("work"), None);
}
#[test]
fn resolves_bookmark_with_subpath() {
let bm_dir = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
std::fs::create_dir_all(target.path().join("sub").join("child")).unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add("work".to_string(), target.path().to_path_buf(), None)
.unwrap();
let base = TempDir::new().unwrap();
let resolved = resolve_path_or_bookmark("work/sub/child", &bookmarks, base.path()).unwrap();
assert_eq!(
resolved,
canonicalize_and_normalize(&target.path().join("sub").join("child")).unwrap()
);
}
#[test]
fn resolves_bare_bookmark_unchanged() {
let bm_dir = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add("work".to_string(), target.path().to_path_buf(), None)
.unwrap();
let base = TempDir::new().unwrap();
let resolved = resolve_path_or_bookmark("work", &bookmarks, base.path()).unwrap();
assert_eq!(resolved, target.path().to_path_buf());
}
#[test]
fn trailing_separator_resolves_to_the_bookmark_itself() {
let bm_dir = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add("work".to_string(), target.path().to_path_buf(), None)
.unwrap();
let base = TempDir::new().unwrap();
let resolved = resolve_path_or_bookmark("work/", &bookmarks, base.path()).unwrap();
assert_eq!(resolved, canonicalize_and_normalize(target.path()).unwrap());
}
#[test]
fn literal_relative_path_wins_over_bookmark_prefix() {
let base = TempDir::new().unwrap();
std::fs::create_dir_all(base.path().join("docs").join("api")).unwrap();
let elsewhere = TempDir::new().unwrap();
std::fs::create_dir_all(elsewhere.path().join("api")).unwrap();
let bm_dir = TempDir::new().unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add("docs".to_string(), elsewhere.path().to_path_buf(), None)
.unwrap();
let resolved = resolve_path_or_bookmark("docs/api", &bookmarks, base.path()).unwrap();
assert_eq!(
resolved,
canonicalize_and_normalize(&base.path().join("docs").join("api")).unwrap()
);
}
#[test]
fn missing_subpath_under_bookmark_is_an_error() {
let bm_dir = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add("work".to_string(), target.path().to_path_buf(), None)
.unwrap();
let base = TempDir::new().unwrap();
let err = resolve_path_or_bookmark("work/nope", &bookmarks, base.path())
.unwrap_err()
.to_string();
assert!(
err.contains("work"),
"error should name the bookmark: {err}"
);
assert!(
err.contains("nope"),
"error should name the missing entry: {err}"
);
}
#[test]
fn unknown_prefix_with_separator_falls_through_to_combined_error() {
let bm_dir = TempDir::new().unwrap();
let bookmarks = bookmarks_in(&bm_dir);
let base = TempDir::new().unwrap();
let err = resolve_path_or_bookmark("nosuch/sub", &bookmarks, base.path())
.unwrap_err()
.to_string();
assert!(err.contains("Neither bookmark"), "{err}");
}
#[test]
fn bookmark_prefix_pointing_to_missing_dir_is_an_error() {
let bm_dir = TempDir::new().unwrap();
let mut bookmarks = bookmarks_in(&bm_dir);
bookmarks
.add(
"ghost".to_string(),
PathBuf::from("/no/such/place/bmrk-test-xyz"),
None,
)
.unwrap();
let base = TempDir::new().unwrap();
let err = resolve_path_or_bookmark("ghost/sub", &bookmarks, base.path())
.unwrap_err()
.to_string();
assert!(err.contains("non-existent"), "{err}");
}
}