diff --git a/Cargo.lock b/Cargo.lock index 2134704..12a1ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,8 @@ name = "fld" version = "0.1.0" dependencies = [ "clap", + "serde", + "serde_json", "thiserror", ] @@ -118,6 +120,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -142,6 +156,49 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "strsim" version = "0.11.1" @@ -205,3 +262,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index e6a464a..6e1c733 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,6 @@ edition = "2024" [dependencies] clap = { version = "4.5.54", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" thiserror = "2.0.18" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b83e23 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# `fld` + +Simple shell utility for creating `cd` aliases. + +## Instalation + +1. Clone and compile the repo + + ``` + git clone https://tangled.org/vielle.dev/fld + cd fld + cargo build --release + ``` + +2. Copy binaries + + ``` + cp ./target/release/fld /usr/bin/fld-bin + cp ./fld.sh /usr/bin/fld + ``` + +3. Add to shell config + + ``` + echo "source /usr/bin/fld" >> ~/.zshrc + ``` + +4. Reload shell and configure + + ``` + fld add alias ~/ home + fld add alias ~/dev dev + fld add alias ~/dev/git dev git + fld add editor code "code ." + fld add editor c "code ." + ``` + +## Cli Reference + +- `fld go [path]`: `cd` into the folder aliased by the given path. Use `-e ` or `--editor ` with an editor to trigger the editor +- `fld add alias [path]`: Create an alias to the given folder at the given path +- `fld add editor `: Create an alias name with an associated command +- `fld remove alias [path]`: Remove the alias at the given path +- `fld remove editor `: Remove the editor with the given name +- `fld list`: List all aliases and editors + +## Config Reference + +```jsonc +{ + // editor map of name > command + "editors": { + "c": "code .", + "code": "code .", + }, + // alias map of root of path > node + // this example includes `git` > `~/dev/git` and `git tangled` to `~/dev/git/tangled` + "roots": { + "git": { + // required, path to navigate to + "path": "~/dev/git", + // required, alias map of path segment > node + "subnodes": { + "tangled": { + // does not need to be a subpath of parent + "path": "~/dev/git/tangled", + "subnodes": {}, + }, + }, + }, + }, +} +``` diff --git a/fld.sh b/fld.sh new file mode 100644 index 0000000..c3cb6c0 --- /dev/null +++ b/fld.sh @@ -0,0 +1,9 @@ + +function fld() { + if [[ "$1" = "go" ]] + then + eval "$(fld-bin $@)" + else + fld-bin $@ + done +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..dd79f4b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,62 @@ +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser, Debug)] +pub enum Cli { + /// Add new aliases/editors + #[command(subcommand)] + Add(Add), + /// Remove aliases/editors + #[command(subcommand)] + Remove(Remove), + /// Display the configuration prettily + List, + /// Navigate to an alias + Go(Go), +} + +#[derive(Subcommand, Debug)] +pub enum Add { + /// Register an alias + Alias { + /// The directory this alias maps to + path: String, + /// The base of the alias (first keyword) + root: String, + /// Subsequent keywords + dir: Vec, + }, + /// Register an editor + Editor { + /// Name of the editor + name: String, + /// Command to run + cmd: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum Remove { + /// Remove an alias + Alias { + /// Base of the alias to remove (first keyword) + root: String, + /// Subsequent keywords + dir: Vec, + }, + /// Remove an editor + Editor { + /// Name of the editor to remove (not command) + name: String, + }, +} + +#[derive(Args, Debug)] +pub struct Go { + /// The base of the alias (first keyword) + pub root: String, + /// Subsequent keywords + pub path: Vec, + /// Editor to open + #[arg(long, short)] + pub editor: Option, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fb6141a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,107 @@ +use std::{collections::BTreeMap, env, fs, io, path::PathBuf, sync::LazyLock}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub static HOME: LazyLock = + LazyLock::new(|| PathBuf::from(env::var("HOME").unwrap_or(String::from("/")))); + +pub static XDG_CONFIG_HOME: LazyLock = LazyLock::new(|| { + env::var("XDG_CONFIG_HOME") + .map(|x| PathBuf::from(x)) + .unwrap_or(HOME.clone()) +}); + +pub static FLD_CONFIG_PATH: LazyLock = LazyLock::new(|| XDG_CONFIG_HOME.join("fld.json")); + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + pub editors: BTreeMap, + pub roots: BTreeMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Node { + pub path: String, + pub subnodes: BTreeMap, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("IO error: {}", .0)] + Io(#[from] std::io::Error), + #[error("Parse error: {}", .0)] + Serde(#[from] serde_json::Error), +} + +impl Config { + pub fn load() -> Result { + match fs::read_to_string(&*FLD_CONFIG_PATH) { + Ok(contents) => Ok(serde_json::from_str(&contents)?), + Err(err) => { + if io::ErrorKind::NotFound == err.kind() { + let cfg = Config::default(); + fs::write(&*FLD_CONFIG_PATH, serde_json::to_string(&cfg)?)?; + Ok(cfg) + } else { + return Err(err.into()); + } + } + } + } + + pub fn save(&self) -> Result<(), Error> { + Ok(fs::write(&*FLD_CONFIG_PATH, serde_json::to_string(&self)?)?) + } + + pub fn get_node(&self, path: &Vec) -> Option<&Node> { + // if no path, None + (path.len() != 0).then(|| ())?; + + let mut map = &self.roots; + let mut path = path.clone(); + let end = path.split_off(path.len() - 1); + + for item in path { + map = &map.get(&item)?.subnodes; + } + map.get(&end[0]) + } + + pub fn get_node_mut(&mut self, path: &Vec) -> Option<&mut Node> { + // if no path, None + (path.len() != 0).then(|| ())?; + + let mut map = &mut self.roots; + let mut path = path.clone(); + let end = path.split_off(path.len() - 1); + + for item in path { + map = &mut map.get_mut(&item)?.subnodes; + } + map.get_mut(&end[0]) + } + + pub fn get_path(&self, path: &Vec) -> Option<&String> { + self.get_node(path).map(|node| &node.path) + } +} + +impl Node { + pub fn display(&self, depth: usize) -> String { + if self.subnodes.len() > 0 { + format!( + "{}\n{} > {}", + self.path, + " ".repeat((depth) * 4), + self.subnodes + .iter() + .map(|(k, v)| { format!("{k} {}", v.display(depth + 1)) }) + .collect::>() + .join(&("\n".to_owned() + &" ".repeat((depth + 1) * 4))) + ) + } else { + self.path.clone() + } + } +} diff --git a/src/main.rs b/src/main.rs index 0883cca..ea8d194 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,62 +1,147 @@ -use std::path::PathBuf; +use std::collections::BTreeMap; -use clap::{Args, Parser}; +use clap::Parser; +use thiserror::Error; -use crate::mapping::Mapping; +mod cli; +mod config; -mod mapping; +use cli::Cli; -#[derive(Parser, Debug, PartialEq, Eq, Clone, Ord, PartialOrd, Hash)] -#[command(version, about, long_about = None)] -struct Cli { - dir: String, - subdirs: Option>, - #[command(flatten)] - editor: Option, -} +use crate::config::Node; -#[derive(Args, Debug, PartialEq, Eq, Clone, Ord, PartialOrd, Hash)] -#[group(required = false, multiple = false)] -struct Editor { - #[arg(long, short = 'c')] - code: bool, - #[arg(long, short = 'z')] - zed: bool, +#[derive(Error, Debug)] +enum Error { + #[error("Error loading config: {}", .0)] + ConfigLoad(#[from] config::Error), + #[error("Missing path: {}", .0.join(" "))] + MissingPath(Vec), + #[error("Entry already exists at {} {}", .0.join(" "), .1)] + AddAlreadyExists(Vec, String), + #[error("Editor shortcut {} already exists", .0)] + AddEditorAlreadyExists(String), + #[error("Editor shortcut {} does not exist", .0)] + RemoveEditorDoesNotExist(String), } -fn main() -> Result<(), ()> { - let args = Cli::parse(); - let mapping = match Mapping::load() { - Ok(val) => val, - Err(err) => { - eprintln!("Error loading mapping: {}", err); - return Err(()); - } - }; - - let Some(target) = mapping.get(&args.dir) else { - eprintln!("{} is not a recognized directory in fld.mapping", args.dir); - return Err(()); - }; - - println!("cd {:?}", target); - - if let Some(editor) = args.editor { - if editor.code { - println!("code ."); - } else { - println!("zed ."); - }; - }; - - if let Some(subdirs) = args.subdirs { - let paths = subdirs.into_iter().map(|x| PathBuf::from(x)); - let mut path = PathBuf::from("./"); - for entry in paths { - path.push(entry); +fn main() { + fn main() -> Result<(), Error> { + let config = config::Config::load()?; + let cli = Cli::parse(); + + match cli { + Cli::Go(go) => { + let mut path = vec![go.root]; + path.append(&mut go.path.clone()); + let Some(path) = config.get_path(&path) else { + Err(Error::MissingPath(path))? + }; + println!("cd {}", path); + if let Some(editor) = go.editor + && let Some(cmd) = config.editors.get(&editor) + { + println!("{}", cmd); + } + Ok(()) + } + Cli::Add(add) => match add { + cli::Add::Alias { path, root, dir } => { + let mut node_path = Vec::with_capacity(dir.len() + 1); + node_path.push(root); + node_path.append(&mut dir.clone()); + let end = node_path.split_off(node_path.len() - 1); + // the vector will always contain at least one string + // so we can always guarantee the len will be >= 1 + let end = unsafe { end.get(end.len() - 1).unwrap_unchecked().clone() }; + + let config = &mut config.clone(); + if node_path.len() > 0 { + let Some(node) = config.get_node_mut(&node_path) else { + return Err(Error::MissingPath(node_path)); + }; + if node.subnodes.get(&end).is_some() { + return Err(Error::AddAlreadyExists(node_path, end)); + } + node.subnodes.insert( + end, + Node { + path, + subnodes: BTreeMap::new(), + }, + ); + } else { + config.roots.insert( + end, + Node { + path, + subnodes: BTreeMap::new(), + }, + ); + } + config.save()?; + Ok(()) + } + cli::Add::Editor { name, cmd } => { + if config.editors.get(&name).is_some() { + return Err(Error::AddEditorAlreadyExists(name)); + } + let config = &mut config.clone(); + config.editors.insert(name, cmd); + config.save()?; + Ok(()) + } + }, + Cli::Remove(remove) => match remove { + cli::Remove::Alias { root, dir } => { + let mut node_path = Vec::with_capacity(dir.len() + 1); + node_path.push(root); + node_path.append(&mut dir.clone()); + let end = node_path.split_off(node_path.len() - 1); + // the vector will always contain at least one string + // so we can always guarantee the len will be >= 1 + let end = unsafe { end.get(end.len() - 1).unwrap_unchecked().clone() }; + + let config = &mut config.clone(); + + if node_path.len() > 0 { + let Some(node) = config.get_node_mut(&node_path) else { + return Err(Error::MissingPath(node_path)); + }; + + if node.subnodes.remove(&end).is_none() { + return Err(Error::MissingPath(node_path)); + } + } else { + config.roots.remove(&end); + } + + config.save()?; + Ok(()) + } + cli::Remove::Editor { name } => { + let config = &mut config.clone(); + if config.editors.remove(&name).is_none() { + return Err(Error::RemoveEditorDoesNotExist(name)); + }; + config.save()?; + Ok(()) + } + }, + Cli::List => { + println!("Editors:"); + for (name, cmd) in config.editors { + println!(" > {}: {}", name, cmd); + } + println!("\nAliases:"); + for (path, node) in config.roots { + println!(" > {} {}", path, node.display(1)); + } + Ok(()) + } } - println!("cd {:?}", path); - }; + } - Ok(()) + if let Err(err) = main() { + eprintln!("{err}"); + } } diff --git a/src/mapping.rs b/src/mapping.rs deleted file mode 100644 index cb62e11..0000000 --- a/src/mapping.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::{ - collections::HashMap, - env, - fs::File, - io::Read, - path::{Path, PathBuf}, - sync::LazyLock, -}; - -use thiserror::Error; - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct Mapping { - internal: HashMap, -} - -#[derive(Error, Debug)] -pub enum Error { - #[error("IO Error: {}", .0)] - IO(#[from] std::io::Error), -} - -static HOME: LazyLock = - LazyLock::new(|| PathBuf::from(env::var("HOME").unwrap_or(String::from("/")))); - -static XDG_CONFIG_HOME: LazyLock = LazyLock::new(|| { - env::var("XDG_CONFIG_HOME") - .map(|x| PathBuf::from(x)) - .unwrap_or(HOME.clone()) -}); - -fn create_config_file() -> Result { - let path: PathBuf = XDG_CONFIG_HOME.join(Path::new("fld.mapping")); - File::create(path) -} - -impl Mapping { - pub fn load() -> Result { - let path: PathBuf = XDG_CONFIG_HOME.join(Path::new("fld.mapping")); - let mut file = File::open(path) - .map(|ok| Ok(ok)) - .unwrap_or_else(|_| create_config_file())?; - let mut str = String::new(); - file.read_to_string(&mut str)?; - - let pairs = str.lines(); - let pairs = pairs.map(|x| x.split("\t").filter(|x| x != &"").collect::>()); - - let pairs = pairs.filter_map(|x| { - if x.len() == 2 { - Some((x[0], x[1])) - } else { - None - } - }); - - let pairs = pairs.map(|(k, v)| { - ( - String::from(k), - if v.starts_with("~") { - PathBuf::from(String::from(HOME.to_string_lossy()) + &v[1..]) - } else { - PathBuf::from(v) - }, - ) - }); - - let pairs = pairs.map(|(k, v)| (k.clone(), v.canonicalize().unwrap_or(v))); - let pairs = pairs.collect(); - - Ok(Mapping { internal: pairs }) - } - - pub fn get(&self, key: &str) -> Option<&PathBuf> { - self.internal.get(&String::from(key)) - } -}