pub mod validate;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::ConfigError;
use crate::theme::CustomTheme;
#[derive(Debug, Deserialize, Default, Clone)]
pub struct Meta {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub theme_file: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Param {
pub name: String,
pub prompt: String,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub secret: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CommandEntry {
pub name: String,
#[serde(default)]
pub description: String,
pub command: String,
#[serde(default)]
pub params: Vec<Param>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Profile {
pub name: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub commands: Vec<CommandEntry>,
}
impl Profile {
pub fn display_label(&self) -> &str {
self.label.as_deref().unwrap_or(&self.name)
}
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub meta: Meta,
#[serde(default)]
pub theme: Option<CustomTheme>,
#[serde(default)]
pub profiles: Vec<Profile>,
}
impl Config {
pub fn all_commands(&self) -> Vec<(&Profile, &CommandEntry)> {
self.profiles
.iter()
.flat_map(|p| p.commands.iter().map(move |c| (p, c)))
.collect()
}
pub fn find_command(&self, name: &str) -> Option<(&Profile, &CommandEntry)> {
self.all_commands().into_iter().find(|(_, c)| c.name == name)
}
pub fn find_profile(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name)
}
pub fn global_id_of_name(&self, name: &str) -> Option<usize> {
self.all_commands().iter().position(|(_, c)| c.name == name).map(|i| i + 1)
}
pub fn resolve_global_id(&self, id: usize) -> Option<(&Profile, &CommandEntry)> {
let (pi, li) = locate_global_id(&self.profiles, id)?;
Some((&self.profiles[pi], &self.profiles[pi].commands[li]))
}
}
pub fn global_id_of(profiles: &[Profile], profile_idx: usize, local_idx: usize) -> usize {
let base: usize = profiles[..profile_idx].iter().map(|p| p.commands.len()).sum();
base + local_idx + 1
}
pub fn locate_global_id(profiles: &[Profile], id: usize) -> Option<(usize, usize)> {
let mut remaining = id.checked_sub(1)?;
for (pi, p) in profiles.iter().enumerate() {
if remaining < p.commands.len() {
return Some((pi, remaining));
}
remaining -= p.commands.len();
}
None
}
pub fn default_config_path() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg).join("exc/config.toml");
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config/exc/config.toml")
}
pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let config: Config = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
Ok(config)
}
pub fn expand_template(template: &str, values: &HashMap<String, String>) -> String {
let mut out = template.to_string();
for (k, v) in values {
out = out.replace(&format!("{{{{{k}}}}}"), v);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_config() {
let raw = std::fs::read_to_string("tests/fixtures/valid.toml").unwrap();
let cfg: Config = toml::from_str(&raw).expect("valid config should parse");
assert_eq!(cfg.profiles.len(), 2);
assert!(cfg.find_command("cert-check-online").is_some());
}
#[test]
fn broken_syntax_reports_line_col() {
let raw = std::fs::read_to_string("tests/fixtures/broken_syntax.toml").unwrap();
let err = toml::from_str::<Config>(&raw).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("line"), "error should mention a line number: {msg}");
}
#[test]
fn expand_template_replaces_placeholders() {
let mut values = HashMap::new();
values.insert("domain".to_string(), "example.com".to_string());
let out = expand_template("openssl s_client -connect {{domain}}:443", &values);
assert_eq!(out, "openssl s_client -connect example.com:443");
}
#[test]
fn expand_template_leaves_unresolved_placeholders_untouched() {
let values = HashMap::new();
let out = expand_template("echo {{missing}}", &values);
assert_eq!(out, "echo {{missing}}");
}
#[test]
fn example_config_preserves_tricky_commands() {
let raw = std::fs::read_to_string("config.toml").unwrap();
let cfg: Config = toml::from_str(&raw).expect("example config should parse");
let cmd = |name: &str| cfg.find_command(name).unwrap().1.command.clone();
assert_eq!(
cmd("git-clean-branches"),
"git fetch --prune; git branch --merged main | grep -v '\\*\\|main'"
);
assert_eq!(cmd("gen-password"), "openssl rand -base64 {{length}} | tr -d '\\n'; echo");
assert_eq!(
cmd("gpg-decrypt"),
r#"file=$(fzf --query '.gpg'); [ -z "$file" ] && echo "No file selected" >&2 || gpg --trust-model always --output "${file%.gpg}" --decrypt "$file""#
);
assert!(cmd("gen-hex-secret").contains(r#"require('crypto').randomBytes(32).toString('hex')"#));
}
#[test]
fn global_ids_are_continuous_and_unambiguous_across_profiles() {
let raw = std::fs::read_to_string("config.toml").unwrap();
let cfg: Config = toml::from_str(&raw).unwrap();
let (p1, c1) = cfg.resolve_global_id(1).unwrap();
assert_eq!(p1.name, "system");
assert_eq!(c1.name, "disk-usage");
let system_len = cfg.profiles[0].commands.len();
let (p2, c2) = cfg.resolve_global_id(system_len + 1).unwrap();
assert_eq!(p2.name, "git");
assert_eq!(c2.name, cfg.profiles[1].commands[0].name);
assert!(cfg.resolve_global_id(0).is_none());
assert!(cfg.resolve_global_id(cfg.all_commands().len() + 1).is_none());
}
#[test]
fn global_id_of_and_locate_global_id_round_trip() {
let raw = std::fs::read_to_string("config.toml").unwrap();
let cfg: Config = toml::from_str(&raw).unwrap();
for (expected_id, (_, _)) in cfg.all_commands().into_iter().enumerate() {
let expected_id = expected_id + 1;
let (pi, li) = locate_global_id(&cfg.profiles, expected_id).unwrap();
assert_eq!(global_id_of(&cfg.profiles, pi, li), expected_id);
}
}
}