use std::{
collections::BTreeMap,
env, fs,
path::{Path, PathBuf},
};
use color_eyre::eyre::{Result, eyre};
use regex::Regex;
use serde_derive::Deserialize;
use super::{ActionMode, CommandAction, CommandConfig, FieldPredicate, Matcher, Predicate};
#[derive(Debug, Default)]
pub struct MatcherBuilder {
commands: Vec<CommandConfig>,
names: BTreeMap<String, (usize, usize)>,
layer: usize,
}
impl MatcherBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn start_layer(&mut self) {
self.layer += 1;
}
pub fn add_str(&mut self, source_name: &str, source: &str) -> Result<()> {
let command = parse_command_config(source_name, source)?;
match self.names.get(&command.name).copied() {
Some((layer, _)) if layer == self.layer => {
return Err(eyre!(
"duplicate command name `{}` in {source_name}",
command.name
));
}
Some((_, index)) => {
let name = command.name.clone();
self.commands[index] = command;
self.names.insert(name, (self.layer, index));
}
None => {
let index = self.commands.len();
self.names.insert(command.name.clone(), (self.layer, index));
self.commands.push(command);
}
}
Ok(())
}
pub fn add_file(&mut self, path: &Path) -> Result<()> {
let source = fs::read_to_string(path)?;
self.add_str(&path.display().to_string(), &source)
}
pub fn build(self) -> Matcher {
Matcher {
commands: self.commands,
}
}
}
pub const SYSTEM_CONFIG_DIR: &str = "/etc/kinjo/commands";
pub fn config_dirs(extra: &[PathBuf]) -> Vec<PathBuf> {
config_dirs_from(env::var_os("XDG_CONFIG_HOME"), env::var_os("HOME"), extra)
}
pub(crate) fn config_dirs_from(
xdg_config_home: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
extra: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from(SYSTEM_CONFIG_DIR)];
if let Some(home) = xdg_config_home {
dirs.push(PathBuf::from(home).join("kinjo").join("commands"));
} else if let Some(home) = home {
dirs.push(
PathBuf::from(home)
.join(".config")
.join("kinjo")
.join("commands"),
);
}
dirs.extend(extra.iter().cloned());
dirs
}
pub fn load_from_dirs(builder: &mut MatcherBuilder, dirs: &[PathBuf]) -> Result<()> {
for dir in dirs {
if !dir.is_dir() {
continue;
}
builder.start_layer();
for path in toml_files_in(dir)? {
builder.add_file(&path)?;
}
}
Ok(())
}
pub fn load_from_dirs_lenient(builder: &mut MatcherBuilder, dirs: &[PathBuf]) -> Vec<String> {
let mut warnings = Vec::new();
for dir in dirs {
if !dir.is_dir() {
continue;
}
builder.start_layer();
let files = match toml_files_in(dir) {
Ok(files) => files,
Err(err) => {
warnings.push(format!("cannot read {}: {err}", dir.display()));
continue;
}
};
for path in files {
if let Err(err) = builder.add_file(&path) {
warnings.push(err.to_string());
}
}
}
warnings
}
fn toml_files_in(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = fs::read_dir(dir)?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
metadata: RawMetadata,
action: RawAction,
#[serde(rename = "match", default)]
matchers: toml::Table,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMetadata {
name: String,
description: Option<String>,
#[serde(default)]
requirements: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAction {
description: Option<String>,
command: String,
mode: String,
}
fn parse_command_config(source_name: &str, source: &str) -> Result<CommandConfig> {
let raw: RawConfig = toml::from_str(source).map_err(|err| eyre!("{source_name}: {err}"))?;
let mode = match raw.action.mode.as_str() {
"fork" => ActionMode::Fork,
"execute" | "exec" => ActionMode::Execute,
value => return Err(eyre!("{source_name}: invalid action mode `{value}`")),
};
let mut predicates = Vec::new();
collect_predicates(source_name, "", &raw.matchers, &mut predicates)?;
if predicates.is_empty() {
return Err(eyre!(
"{source_name}: command `{}` has no match predicates",
raw.metadata.name
));
}
Ok(CommandConfig {
name: raw.metadata.name,
description: raw.metadata.description,
requirements: raw.metadata.requirements,
predicates,
action: CommandAction {
description: raw.action.description,
command: raw.action.command,
mode,
},
})
}
fn collect_predicates(
source_name: &str,
field: &str,
table: &toml::Table,
predicates: &mut Vec<FieldPredicate>,
) -> Result<()> {
for (key, value) in table {
let path = if field.is_empty() {
key.clone()
} else {
format!("{field}.{key}")
};
match value {
toml::Value::Table(nested) => {
collect_predicates(source_name, &path, nested, predicates)?;
}
_ if field.is_empty() => {
return Err(eyre!(
"{source_name}: match `{path}` must be a table like `[match.<field>]`"
));
}
toml::Value::String(value) => {
let predicate = match key.as_str() {
"equals" => Predicate::Equals(value.clone()),
"contains" => Predicate::Contains(value.clone()),
"regex" => Predicate::Regex(Regex::new(value).map_err(|err| {
eyre!("{source_name}: invalid regex for `{path}`: {err}")
})?),
_ => return Err(eyre!("{source_name}: unsupported predicate `{path}`")),
};
predicates.push(FieldPredicate {
field: field.to_string(),
predicate,
});
}
_ => return Err(eyre!("{source_name}: match `{path}` must be a string")),
}
}
Ok(())
}