use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow};
use directories::BaseDirs;
use regex::Regex;
use serde::Deserialize;
use crate::input::InputKind;
pub const DEFAULT_CONFIG_TOML: &str = include_str!("../assets/default.toml");
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub vars: BTreeMap<String, toml::Value>,
#[serde(default)]
pub todoke: BTreeMap<String, Target>,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Target {
#[serde(default)]
pub kind: TargetKind,
pub command: String,
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub args: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub append_inputs: Option<bool>,
#[serde(default)]
pub append_passthrough: Option<bool>,
#[serde(default)]
pub gui: bool,
}
impl Target {
pub fn args_for(&self, mode: &str) -> &[String] {
self.args
.get(mode)
.or_else(|| self.args.get("default"))
.map(Vec::as_slice)
.unwrap_or(&[])
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TargetKind {
#[default]
Exec,
Neovim,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Rule {
#[serde(default)]
pub name: Option<String>,
#[serde(rename = "match")]
pub match_: StringOrVec,
#[serde(default)]
pub exclude: Option<StringOrVec>,
pub to: String,
#[serde(default)]
pub group: Option<String>,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default)]
pub sync: bool,
#[serde(default)]
pub input_type: Option<InputTypes>,
#[serde(default)]
pub joined: bool,
#[serde(default)]
pub passthrough: bool,
#[serde(default)]
pub consumes: usize,
#[serde(default)]
pub consumes_until: Option<String>,
#[serde(default)]
pub consumes_rest: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum InputTypes {
One(InputKind),
Many(Vec<InputKind>),
}
impl InputTypes {
pub fn contains(&self, kind: InputKind) -> bool {
match self {
InputTypes::One(k) => *k == kind,
InputTypes::Many(v) => v.contains(&kind),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StringOrVec {
One(String),
Many(Vec<String>),
}
impl StringOrVec {
pub fn as_slice(&self) -> Vec<&str> {
match self {
StringOrVec::One(s) => vec![s.as_str()],
StringOrVec::Many(v) => v.iter().map(String::as_str).collect(),
}
}
}
pub const DEFAULT_GROUP: &str = "default";
pub const DEFAULT_MODE: &str = "remote";
fn default_mode() -> String {
DEFAULT_MODE.to_string()
}
fn is_template(s: &str) -> bool {
s.contains("{{") || s.contains("{%")
}
#[derive(Debug)]
pub struct ResolvedConfig {
pub raw: Config,
pub rule_regexes: Vec<Vec<Regex>>,
pub rule_excludes: Vec<Vec<Regex>>,
pub rule_consumes_until: Vec<Option<Regex>>,
}
impl ResolvedConfig {
pub fn rule(&self, idx: usize) -> &Rule {
&self.raw.rules[idx]
}
pub fn target(&self, name: &str) -> Result<&Target> {
self.raw
.todoke
.get(name)
.ok_or_else(|| anyhow!("rule references unknown todoke target: {name}"))
}
fn compile(raw: Config) -> Result<Self> {
for (i, rule) in raw.rules.iter().enumerate() {
if rule.joined && rule.passthrough {
return Err(anyhow!(
"rule[{i}] ({}) sets both joined = true and passthrough = true — these are mutually exclusive; joined already lets args templates place captures anywhere, so passthrough is redundant",
rule.name.as_deref().unwrap_or("<unnamed>"),
));
}
let consumes_forms = (rule.consumes > 0) as u8
+ rule.consumes_until.is_some() as u8
+ rule.consumes_rest as u8;
if consumes_forms > 1 {
return Err(anyhow!(
"rule[{i}] ({}) sets more than one of consumes / consumes_until / consumes_rest — pick exactly one",
rule.name.as_deref().unwrap_or("<unnamed>"),
));
}
if consumes_forms > 0 && !rule.passthrough {
return Err(anyhow!(
"rule[{i}] ({}) has consumes* set but passthrough = false — consume options only apply to passthrough rules",
rule.name.as_deref().unwrap_or("<unnamed>"),
));
}
if is_template(&rule.to) {
continue;
}
if !raw.todoke.contains_key(&rule.to) {
return Err(anyhow!(
"rule[{i}] ({}) references unknown todoke target '{}'. Known targets: {}",
rule.name.as_deref().unwrap_or("<unnamed>"),
rule.to,
raw.todoke.keys().cloned().collect::<Vec<_>>().join(", ")
));
}
}
let rule_regexes = raw
.rules
.iter()
.enumerate()
.map(|(i, rule)| {
rule.match_
.as_slice()
.iter()
.map(|p| {
Regex::new(p).with_context(|| {
format!("rule[{i}]: failed to compile match pattern '{p}'")
})
})
.collect::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
let rule_excludes = raw
.rules
.iter()
.enumerate()
.map(|(i, rule)| match &rule.exclude {
None => Ok(Vec::new()),
Some(patterns) => patterns
.as_slice()
.iter()
.map(|p| {
Regex::new(p).with_context(|| {
format!("rule[{i}]: failed to compile exclude pattern '{p}'")
})
})
.collect::<Result<Vec<_>>>(),
})
.collect::<Result<Vec<_>>>()?;
let rule_consumes_until = raw
.rules
.iter()
.enumerate()
.map(|(i, rule)| match &rule.consumes_until {
None => Ok(None),
Some(p) => Regex::new(p)
.map(Some)
.with_context(|| format!("rule[{i}]: failed to compile consumes_until '{p}'")),
})
.collect::<Result<Vec<_>>>()?;
Ok(Self {
raw,
rule_regexes,
rule_excludes,
rule_consumes_until,
})
}
}
pub fn resolve_path(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
if let Ok(env_path) = std::env::var("TODOKE_CONFIG") {
return Ok(PathBuf::from(env_path));
}
let home = BaseDirs::new()
.map(|d| d.home_dir().to_path_buf())
.ok_or_else(|| anyhow!("could not determine home directory"))?;
Ok(home.join(".config").join("todoke").join("todoke.toml"))
}
pub fn load(explicit: Option<&Path>) -> Result<ResolvedConfig> {
let path = resolve_path(explicit)?;
let (text, source) = if path.exists() {
let t = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
(t, Some(path))
} else {
(DEFAULT_CONFIG_TOML.to_string(), None)
};
let rendered = prerender(&text).with_context(|| {
source
.as_ref()
.map(|p| format!("Tera pre-render failed for {}", p.display()))
.unwrap_or_else(|| "Tera pre-render failed for embedded default TOML".into())
})?;
let raw: Config = toml::from_str(&rendered).with_context(|| {
source
.as_ref()
.map(|p| format!("failed to parse TOML at {}", p.display()))
.unwrap_or_else(|| "failed to parse embedded default TOML".into())
})?;
ResolvedConfig::compile(raw)
}
#[allow(dead_code)]
pub fn load_from_str(text: &str) -> Result<ResolvedConfig> {
let rendered = prerender(text).context("Tera pre-render failed")?;
let raw: Config = toml::from_str(&rendered).context("failed to parse TOML")?;
ResolvedConfig::compile(raw)
}
fn prerender(text: &str) -> Result<String> {
let vars = extract_vars(text);
let mut tera = crate::template::new_engine();
let mut ctx = tera::Context::new();
let vars_map: HashMap<String, toml::Value> = vars.into_iter().collect();
ctx.insert("vars", &vars_map);
let env_map: HashMap<String, String> = std::env::vars().collect();
ctx.insert("env", &env_map);
for name in [
"input",
"input_type",
"file_path",
"file_dir",
"file_name",
"file_stem",
"file_ext",
"url_scheme",
"url_host",
"url_port",
"url_path",
"url_query",
"url_fragment",
"command_path",
"command_dir",
"command_name",
"command_stem",
"command_ext",
"cwd",
"group",
"rule",
] {
ctx.insert(name, &format!("{{{{ {name} }}}}"));
}
tera.render_str(text, &ctx).map_err(|e| {
let mut msg = e.to_string();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = src {
msg.push_str(&format!("\n caused by: {s}"));
src = s.source();
}
anyhow!(msg)
})
}
fn extract_vars(text: &str) -> BTreeMap<String, toml::Value> {
let mut buf = String::new();
let mut in_vars = false;
for line in text.lines() {
let tr = line.trim_start();
if let Some(rest) = tr.strip_prefix('[') {
let is_aot = rest.starts_with('[');
let inner = rest
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or("")
.trim();
in_vars = !is_aot && (inner == "vars" || inner.starts_with("vars."));
}
if in_vars {
buf.push_str(line);
buf.push('\n');
}
}
if buf.is_empty() {
return BTreeMap::new();
}
let tera_block = Regex::new(r"(?s)\{%.*?%\}").expect("static regex");
let cleaned = tera_block.replace_all(&buf, "");
#[derive(Deserialize, Default)]
struct VarsOnly {
#[serde(default)]
vars: BTreeMap<String, toml::Value>,
}
toml::from_str::<VarsOnly>(&cleaned)
.map(|w| w.vars)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_config() {
let cfg = load_from_str(DEFAULT_CONFIG_TOML).expect("default config must parse");
assert!(cfg.raw.todoke.contains_key("nvim"));
assert_eq!(cfg.raw.rules.len(), 2);
assert_eq!(cfg.raw.rules[0].name.as_deref(), Some("editor-callback"));
assert_eq!(cfg.raw.rules[1].name.as_deref(), Some("default"));
assert!(cfg.raw.rules[0].sync);
assert!(!cfg.raw.rules[1].sync);
}
#[test]
fn rejects_unknown_to_reference() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = ".*"
to = "does-not-exist"
"#;
let err = load_from_str(text).unwrap_err();
assert!(
err.to_string().contains("unknown todoke target"),
"got: {err}"
);
}
#[test]
fn rejects_invalid_regex() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = "[unterminated"
to = "a"
"#;
let err = load_from_str(text).unwrap_err();
assert!(
err.to_string().contains("failed to compile match pattern"),
"got: {err}"
);
}
#[test]
fn rejects_multiple_consume_forms() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
to = "a"
passthrough = true
consumes = 1
consumes_rest = true
"#;
let err = load_from_str(text).unwrap_err();
assert!(err.to_string().contains("pick exactly one"), "got: {err}");
}
#[test]
fn rejects_consumes_until_without_passthrough() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
to = "a"
consumes_until = '^[-+]'
"#;
let err = load_from_str(text).unwrap_err();
assert!(
err.to_string().contains("consume options only apply"),
"got: {err}"
);
}
#[test]
fn rejects_invalid_consumes_until_regex() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
to = "a"
passthrough = true
consumes_until = '[unterminated'
"#;
let err = load_from_str(text).unwrap_err();
assert!(err.to_string().contains("consumes_until"), "got: {err}");
}
#[test]
fn rejects_consumes_without_passthrough() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
to = "a"
consumes = 1
"#;
let err = load_from_str(text).unwrap_err();
assert!(
err.to_string().contains("consume options only apply"),
"got: {err}"
);
}
#[test]
fn rejects_joined_and_passthrough_both_true() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
to = "a"
joined = true
passthrough = true
"#;
let err = load_from_str(text).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
}
#[test]
fn mode_defaults_to_remote_kind_defaults_to_exec() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = ".*"
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(cfg.raw.rules[0].mode, "remote");
assert!(!cfg.raw.rules[0].sync);
assert!(cfg.raw.rules[0].group.is_none());
assert_eq!(cfg.raw.todoke["a"].kind, TargetKind::Exec);
assert!(!cfg.raw.todoke["a"].gui);
}
#[test]
fn target_gui_parses_true() {
let text = r#"
[todoke.a]
command = "neovide"
gui = true
[[rules]]
match = ".*"
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert!(cfg.raw.todoke["a"].gui);
}
#[test]
fn args_per_mode_with_default_fallback() {
let text = r#"
[todoke.a]
command = "echo"
[todoke.a.args]
remote = ["--reuse"]
default = ["--fallback"]
[[rules]]
match = ".*"
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
let t = &cfg.raw.todoke["a"];
assert_eq!(t.args_for("remote"), &["--reuse".to_string()]);
assert_eq!(t.args_for("new"), &["--fallback".to_string()]);
assert_eq!(t.args_for("anything-else"), &["--fallback".to_string()]);
}
#[test]
fn tera_conditional_blocks_are_applied_at_load_time() {
let src = r#"
[vars]
use_neovide = true
[todoke.nvim]
kind = "neovim"
command = "nvim"
listen = "/tmp/sock"
{% if vars.use_neovide %}
[todoke.nvim-gui]
kind = "neovim"
command = "neovide"
listen = "/tmp/sock-gui"
[todoke.nvim-gui.args]
remote = ["--"]
{% endif %}
[[rules]]
match = ".*"
to = "nvim"
"#;
let cfg = load_from_str(src).unwrap();
assert!(cfg.raw.todoke.contains_key("nvim-gui"));
let src_off = src.replace("use_neovide = true", "use_neovide = false");
let cfg2 = load_from_str(&src_off).unwrap();
assert!(!cfg2.raw.todoke.contains_key("nvim-gui"));
assert!(cfg2.raw.todoke.contains_key("nvim"));
}
#[test]
fn dispatch_time_placeholders_survive_prerender() {
let src = r#"
[todoke.nvim]
kind = "neovim"
command = "nvim"
listen = '/tmp/nvim-todoke-{{ group }}.sock'
[[rules]]
match = ".*"
to = "nvim"
group = "{{ file_stem }}"
"#;
let cfg = load_from_str(src).unwrap();
assert_eq!(
cfg.raw.todoke["nvim"].listen.as_deref(),
Some("/tmp/nvim-todoke-{{ group }}.sock"),
);
assert_eq!(cfg.raw.rules[0].group.as_deref(), Some("{{ file_stem }}"));
}
#[test]
fn vars_value_substitutes_top_level() {
let src = r#"
[vars]
gui = "neovide"
[todoke.nvim]
kind = "neovim"
command = "{{ vars.gui }}"
listen = "/tmp/sock"
[[rules]]
match = ".*"
to = "nvim"
"#;
let cfg = load_from_str(src).unwrap();
assert_eq!(cfg.raw.todoke["nvim"].command, "neovide");
}
#[test]
fn vars_subtables_are_picked_up() {
let src = r#"
[vars]
gui = "neovide"
[vars.colors]
primary = "blue"
[todoke.nvim]
kind = "neovim"
command = "{{ vars.gui }}"
listen = "/tmp/{{ vars.colors.primary }}"
[[rules]]
match = ".*"
to = "nvim"
"#;
let cfg = load_from_str(src).unwrap();
assert_eq!(cfg.raw.todoke["nvim"].command, "neovide");
assert_eq!(cfg.raw.todoke["nvim"].listen.as_deref(), Some("/tmp/blue"));
}
}