//! Command aliases — short abbreviations typed at the command line that expand //! to a full command (e.g. `L` → `LINE`). The alias table is the single source //! of truth: it is loaded from a user-editable `aliases.pgp` file at startup and //! consulted before every command dispatch (see `resolve_alias`), so users can //! add, remap, or delete aliases without touching the app. //! //! File format mirrors the long-standing plain-text `.pgp` convention so it is //! familiar and hand-editable, stored as `ocad.pgp`: //! //! ```text //! ; lines beginning with a semicolon are comments //! L, *LINE //! CC, *COPYCLIP //! ``` //! //! An alias is the token left of the comma; the command is the token right of //! it, with an optional leading `*`. Both are matched case-insensitively and //! stored uppercased. Stored next to the other per-user config under //! `crate::config::config_dir()`, so no serialization crate is pulled in. use super::OpenCADStudio; use rustc_hash::FxHashMap; use std::path::PathBuf; /// The shipped default alias file, embedded at compile time. Its aliases live in /// `assets/ocad.pgp` — not in Rust — so the defaults are just data in the same /// format the user edits. On first launch this text is written verbatim to the /// user's config folder (comments and all) as the starting `ocad.pgp`; only that /// user copy is read thereafter. This embedded copy is the first-run seed / /// factory default. const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp"); /// Path to the user's alias file, `/ocad.pgp`. `None` when the platform /// config base can't be resolved (headless, no `HOME`, wasm). fn alias_file_path() -> Option { Some(crate::config::config_dir()?.join("ocad.pgp")) } /// Parse `.pgp` text into an `alias → command` map, both uppercased. Skips blank /// lines and `;` comments; tolerates a leading `*` on the command and arbitrary /// whitespace padding. fn parse_pgp(body: &str) -> FxHashMap { let mut map = FxHashMap::default(); for line in body.lines() { let line = line.trim(); if line.is_empty() || line.starts_with(';') { continue; } let Some((alias, cmd)) = line.split_once(',') else { continue; }; let alias = alias.trim().to_uppercase(); let cmd = cmd.trim().trim_start_matches('*').trim().to_uppercase(); if alias.is_empty() || cmd.is_empty() { continue; } map.insert(alias, cmd); } map } /// Serialize an `alias → command` map to `.pgp` text with a header and aligned /// columns, sorted by alias for a stable, diffable file. pub(super) fn to_pgp(map: &FxHashMap) -> String { let mut rows: Vec<(&String, &String)> = map.iter().collect(); rows.sort_by(|a, b| a.0.cmp(b.0)); let mut out = String::from( "; OpenCADStudio command aliases.\n\ ; Format: ALIAS,*COMMAND (lines starting with ';' are comments)\n\ ; Edit here or via the ALIASEDIT command. One alias per line.\n\n", ); for (alias, cmd) in rows { out.push_str(&format!("{:<10}*{}\n", format!("{alias},"), cmd)); } out } /// The built-in defaults, parsed from the embedded `.pgp`. Used when no user /// file exists yet (or can't be reached, e.g. wasm). fn default_map() -> FxHashMap { parse_pgp(DEFAULT_ALIASES_PGP) } /// Load the alias table at boot. Reads the user's `aliases.pgp`; if it is /// missing, writes the shipped default file verbatim (preserving its comments /// and layout) so the user has a well-formatted starting point to edit, then /// parses it. Falls back to the embedded defaults when the file can't be read /// or written (read-only home, wasm — where it silently no-ops). pub(super) fn load_aliases() -> FxHashMap { match alias_file_path() { Some(path) => match std::fs::read_to_string(&path) { Ok(body) => parse_pgp(&body), Err(_) => { // No file yet — copy the shipped default file, best-effort. if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } let _ = std::fs::write(&path, DEFAULT_ALIASES_PGP); default_map() } }, None => default_map(), } } /// Persist the alias table to `aliases.pgp`. Best-effort; returns `Ok` (no-op) /// when there is no config dir (wasm/headless). pub(super) fn save_map(map: &FxHashMap) -> std::io::Result<()> { let Some(path) = alias_file_path() else { return Ok(()); }; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(path, to_pgp(map)) } impl OpenCADStudio { /// Rewrite the leading command token through the alias table, leaving any /// arguments after the first whitespace untouched. Returns `None` when the /// verb is not an alias, so the caller passes the original string through /// unchanged. Keyed uppercase so lowercase aliases from scripts/plugins /// (which bypass the command line's verb-uppercasing) still resolve. pub(super) fn resolve_alias(&self, cmd: &str) -> Option { if self.command_aliases.is_empty() { return None; } let (verb, rest) = match cmd.split_once(char::is_whitespace) { Some((v, r)) => (v, Some(r)), None => (cmd, None), }; let canon = self.command_aliases.get(&verb.to_uppercase())?; Some(match rest { Some(r) => format!("{canon} {r}"), None => canon.clone(), }) } /// Replace the alias table (from the editor), persist it, and mirror it into /// the command line so autocomplete reflects the edits immediately. pub(super) fn set_command_aliases(&mut self, map: FxHashMap) { let _ = save_map(&map); self.command_line.command_aliases = map.clone(); self.command_aliases = map; } /// Commit the alias-editor working rows to the table (Apply button): build a /// map from the rows, dropping incomplete ones, and persist + sync. Leaves /// the editor open so the user can keep editing. pub(super) fn apply_alias_editor_rows(&mut self) { let map: FxHashMap = self .alias_editor_rows .iter() .filter(|(a, c)| !a.trim().is_empty() && !c.trim().is_empty()) .map(|(a, c)| (a.trim().to_string(), c.trim().to_string())) .collect(); self.set_command_aliases(map); } }