use std::{
collections::{BTreeMap, HashSet},
env, fs,
path::{Path, PathBuf},
time::Duration,
};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE: &str = "demons.toml";
pub const DEFAULT_MULTI_CLICK_MS: u64 = 500;
pub const MIN_MULTI_CLICK_MS: u64 = 150;
pub const MAX_MULTI_CLICK_MS: u64 = 1000;
pub const MULTI_CLICK_STEP_MS: u64 = 50;
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default, skip_serializing_if = "Settings::is_default")]
pub settings: Settings,
#[serde(rename = "task")]
pub tasks: Vec<Task>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub layout: Layout,
pub leader: Leader,
#[serde(
default = "default_multi_click_ms",
skip_serializing_if = "is_default_multi_click_ms"
)]
pub multi_click_ms: u64,
#[serde(skip_serializing_if = "is_false")]
pub logging: bool,
}
impl Settings {
fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for Settings {
fn default() -> Self {
Self {
layout: Layout::Grid,
leader: Leader::AltJ,
multi_click_ms: DEFAULT_MULTI_CLICK_MS,
logging: false,
}
}
}
fn default_multi_click_ms() -> u64 {
DEFAULT_MULTI_CLICK_MS
}
fn is_default_multi_click_ms(value: &u64) -> bool {
*value == DEFAULT_MULTI_CLICK_MS
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Layout {
#[default]
Grid,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Leader {
#[serde(rename = "alt-j")]
#[default]
AltJ,
#[serde(rename = "alt-backtick")]
AltBacktick,
Tab,
CtrlB,
CtrlQ,
#[serde(rename = "ctrl-\\")]
CtrlBackslash,
}
impl Leader {
pub fn label(self) -> &'static str {
match self {
Self::AltJ => "Alt-J",
Self::AltBacktick => "Alt-`",
Self::Tab => "Tab",
Self::CtrlB => "Ctrl-B",
Self::CtrlQ => "Ctrl-Q",
Self::CtrlBackslash => "Ctrl-\\",
}
}
pub fn uses_escape_alt_encoding(self) -> bool {
matches!(self, Self::AltJ | Self::AltBacktick)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Task {
pub name: String,
pub command: TaskCommand,
#[serde(default = "default_cwd", skip_serializing_if = "is_default_cwd")]
pub cwd: PathBuf,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_delay: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_on_change: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum TaskCommand {
Shell(String),
Direct(Vec<String>),
}
impl TaskCommand {
pub fn display(&self) -> String {
match self {
Self::Shell(command) => command.clone(),
Self::Direct(parts) => parts.join(" "),
}
}
fn is_empty(&self) -> bool {
match self {
Self::Shell(command) => command.trim().is_empty(),
Self::Direct(parts) => parts.is_empty() || parts[0].trim().is_empty(),
}
}
fn contains_nul(&self) -> bool {
match self {
Self::Shell(command) => command.contains('\0'),
Self::Direct(parts) => parts.iter().any(|part| part.contains('\0')),
}
}
}
#[derive(Clone, Debug)]
pub struct LoadedConfig {
pub path: PathBuf,
pub root: PathBuf,
pub config: Config,
}
impl LoadedConfig {
pub fn load(path: PathBuf) -> Result<Self> {
let path = absolute_path(path)?;
let config = parse_file(&path)?;
let root = path
.parent()
.context("config path has no parent directory")?
.to_path_buf();
let loaded = Self { path, root, config };
loaded.validate()?;
Ok(loaded)
}
pub fn load_unvalidated_or_default(path: PathBuf) -> Result<Self> {
let path = absolute_path(path)?;
let config = if path.is_file() {
parse_file(&path)?
} else {
Config::default()
};
let root = path
.parent()
.context("config path has no parent directory")?
.to_path_buf();
Ok(Self { path, root, config })
}
pub fn validate(&self) -> Result<()> {
validate_for_path(&self.config, &self.path)
}
pub fn save(&self) -> Result<()> {
self.validate()?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create config directory {}", parent.display())
})?;
}
let text = toml::to_string_pretty(&self.config)
.with_context(|| format!("failed to serialize config {}", self.path.display()))?;
fs::write(&self.path, text)
.with_context(|| format!("failed to write config {}", self.path.display()))
}
pub fn task_cwd(&self, task: &Task) -> PathBuf {
if task.cwd.is_absolute() {
task.cwd.clone()
} else {
self.root.join(&task.cwd)
}
}
}
pub fn parse_file(path: &Path) -> Result<Config> {
let source = fs::read_to_string(path)
.with_context(|| format!("failed to read config {}", path.display()))?;
toml::from_str(&source).with_context(|| format!("failed to parse config {}", path.display()))
}
pub fn validate_for_path(config: &Config, path: &Path) -> Result<()> {
if config.tasks.is_empty() {
bail!("{} must define at least one [[task]]", path.display());
}
if config.settings.logging {
bail!(
"{}: settings.logging is reserved for a future release and cannot be enabled",
path.display()
);
}
if !(MIN_MULTI_CLICK_MS..=MAX_MULTI_CLICK_MS).contains(&config.settings.multi_click_ms) {
bail!(
"{}: settings.multi_click_ms must be between {MIN_MULTI_CLICK_MS} and {MAX_MULTI_CLICK_MS}",
path.display()
);
}
let root = path
.parent()
.context("config path has no parent directory")?;
let mut names = HashSet::new();
let mut name_to_index = BTreeMap::new();
for (index, task) in config.tasks.iter().enumerate() {
let label = format!("task #{}", index + 1);
if task.name.trim().is_empty() {
bail!("{}: {label} has an empty name", path.display());
}
if task.name.trim() != task.name || task.name.chars().any(char::is_control) {
bail!(
"{}: task name {:?} has leading/trailing whitespace or control characters",
path.display(),
task.name
);
}
if !names.insert(task.name.as_str()) {
bail!("{}: duplicate task name {:?}", path.display(), task.name);
}
name_to_index.insert(task.name.as_str(), index);
if task.command.is_empty() {
bail!(
"{}: task {:?} has an empty command",
path.display(),
task.name
);
}
if task.command.contains_nul() {
bail!(
"{}: task {:?} command contains a NUL byte",
path.display(),
task.name
);
}
for (key, value) in &task.env {
if key.is_empty()
|| key.contains(['=', '\0'])
|| key.contains(char::is_whitespace)
|| value.contains('\0')
{
bail!(
"{}: task {:?} has an invalid environment entry for key {:?}",
path.display(),
task.name,
key
);
}
}
let mut dependencies = HashSet::new();
for dependency in &task.depends_on {
if dependency.trim().is_empty()
|| dependency.trim() != dependency
|| dependency.chars().any(char::is_control)
{
bail!(
"{}: task {:?} has an invalid dependency name {:?}",
path.display(),
task.name,
dependency
);
}
if dependency == &task.name {
bail!(
"{}: task {:?} cannot depend on itself",
path.display(),
task.name
);
}
if !dependencies.insert(dependency.as_str()) {
bail!(
"{}: task {:?} repeats dependency {:?}",
path.display(),
task.name,
dependency
);
}
}
if let Some(delay) = task.start_delay.as_deref() {
parse_start_delay(delay).with_context(|| {
format!(
"{}: task {:?} has invalid start_delay {:?}",
path.display(),
task.name,
delay
)
})?;
}
let cwd = if task.cwd.is_absolute() {
task.cwd.clone()
} else {
root.join(&task.cwd)
};
if !cwd.is_dir() {
bail!(
"{}: cwd for task {:?} is not a directory: {}",
path.display(),
task.name,
cwd.display()
);
}
if task.watch.is_some() || task.run_on_change.is_some() || task.repeat.is_some() {
bail!(
"{}: task {:?} uses watch, run_on_change, or repeat; these fields are reserved for \
a future release",
path.display(),
task.name
);
}
}
for task in &config.tasks {
for dependency in &task.depends_on {
if !name_to_index.contains_key(dependency.as_str()) {
bail!(
"{}: task {:?} depends on unknown task {:?}",
path.display(),
task.name,
dependency
);
}
}
}
reject_dependency_cycles(config, &name_to_index, path)?;
Ok(())
}
pub fn parse_start_delay(value: &str) -> Result<Duration> {
let value = value.trim();
if value.is_empty() {
bail!("delay cannot be empty");
}
let (number, unit) = split_duration(value)?;
let amount: u64 = number
.parse()
.with_context(|| format!("invalid delay amount {number:?}"))?;
if amount == 0 {
return Ok(Duration::ZERO);
}
let millis = match unit {
"" | "s" => amount.checked_mul(1000).context("delay is too large")?,
"ms" => amount,
"m" => amount.checked_mul(60_000).context("delay is too large")?,
"h" => amount
.checked_mul(3_600_000)
.context("delay is too large")?,
_ => bail!("delay unit must be one of ms, s, m, h"),
};
Ok(Duration::from_millis(millis))
}
fn split_duration(value: &str) -> Result<(&str, &str)> {
let unit_start = value
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(value.len());
let number = &value[..unit_start];
let unit = &value[unit_start..];
if number.is_empty() || !number.chars().all(|character| character.is_ascii_digit()) {
bail!("delay must start with a number");
}
if unit.is_empty()
|| unit
.chars()
.all(|character| character.is_ascii_alphabetic())
{
Ok((number, unit))
} else {
bail!("delay unit must use letters only");
}
}
fn reject_dependency_cycles(
config: &Config,
name_to_index: &BTreeMap<&str, usize>,
path: &Path,
) -> Result<()> {
let mut states = vec![VisitState::Unvisited; config.tasks.len()];
let mut stack = Vec::new();
for index in 0..config.tasks.len() {
visit_dependency(index, config, name_to_index, &mut states, &mut stack, path)?;
}
Ok(())
}
fn visit_dependency(
index: usize,
config: &Config,
name_to_index: &BTreeMap<&str, usize>,
states: &mut [VisitState],
stack: &mut Vec<usize>,
path: &Path,
) -> Result<()> {
match states[index] {
VisitState::Visited => return Ok(()),
VisitState::Visiting => {
let task = &config.tasks[index];
bail!(
"{}: task dependency cycle includes {:?}",
path.display(),
task.name
);
}
VisitState::Unvisited => {}
}
states[index] = VisitState::Visiting;
stack.push(index);
for dependency in &config.tasks[index].depends_on {
let Some(&dependency_index) = name_to_index.get(dependency.as_str()) else {
continue;
};
if states[dependency_index] == VisitState::Visiting {
let start = stack
.iter()
.position(|candidate| *candidate == dependency_index)
.unwrap_or(0);
let mut cycle = stack[start..]
.iter()
.map(|candidate| config.tasks[*candidate].name.as_str())
.collect::<Vec<_>>();
cycle.push(config.tasks[dependency_index].name.as_str());
bail!(
"{}: task dependency cycle: {}",
path.display(),
cycle.join(" -> ")
);
}
visit_dependency(dependency_index, config, name_to_index, states, stack, path)?;
}
stack.pop();
states[index] = VisitState::Visited;
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VisitState {
Unvisited,
Visiting,
Visited,
}
pub fn discover(start: &Path) -> Result<Option<PathBuf>> {
let mut directory = absolute_path(start.to_path_buf())?;
if directory.is_file() {
directory.pop();
}
loop {
let candidate = directory.join(CONFIG_FILE);
if candidate.is_file() {
return Ok(Some(candidate));
}
if !directory.pop() {
return Ok(None);
}
}
}
pub fn explicit_or_discover(explicit: Option<PathBuf>, start: &Path) -> Result<Option<PathBuf>> {
match explicit {
Some(path) => Ok(Some(absolute_path(path)?)),
None => discover(start),
}
}
fn absolute_path(path: PathBuf) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path)
} else {
Ok(env::current_dir()
.context("failed to determine current directory")?
.join(path))
}
}
fn default_cwd() -> PathBuf {
PathBuf::from(".")
}
fn is_default_cwd(path: &Path) -> bool {
path == Path::new(".")
}
fn is_false(value: &bool) -> bool {
!value
}
#[cfg(test)]
mod tests {
use std::{fs, time::Duration};
use tempfile::tempdir;
use super::*;
#[test]
fn discovers_closest_config() {
let temp = tempdir().unwrap();
let root = temp.path();
let nested = root.join("a/b");
fs::create_dir_all(&nested).unwrap();
fs::write(root.join(CONFIG_FILE), "").unwrap();
fs::write(root.join("a").join(CONFIG_FILE), "").unwrap();
assert_eq!(
discover(&nested).unwrap(),
Some(root.join("a").join(CONFIG_FILE))
);
}
#[test]
fn rejects_unknown_keys() {
let error = toml::from_str::<Config>(
r#"
surprise = true
[[task]]
name = "test"
command = "echo ok"
"#,
)
.unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn parses_shell_and_direct_commands() {
let config: Config = toml::from_str(
r#"
[[task]]
name = "shell"
command = "echo shell"
[[task]]
name = "direct"
command = ["echo", "direct"]
"#,
)
.unwrap();
assert!(matches!(
config.tasks[0].command,
TaskCommand::Shell(ref command) if command == "echo shell"
));
assert!(matches!(
config.tasks[1].command,
TaskCommand::Direct(ref command) if command == &["echo", "direct"]
));
}
#[test]
fn allows_empty_direct_arguments() {
let config: Config = toml::from_str(
r#"
[[task]]
name = "direct"
command = ["printf", "%s", ""]
"#,
)
.unwrap();
assert!(!config.tasks[0].command.is_empty());
}
#[test]
fn validates_relative_working_directories_and_unique_names() {
let temp = tempdir().unwrap();
fs::create_dir(temp.path().join("web")).unwrap();
let path = temp.path().join(CONFIG_FILE);
let valid: Config = toml::from_str(
r#"
[[task]]
name = "server"
command = "echo server"
[[task]]
name = "web"
command = "echo web"
cwd = "web"
"#,
)
.unwrap();
validate_for_path(&valid, &path).unwrap();
let duplicate: Config = toml::from_str(
r#"
[[task]]
name = "server"
command = "echo one"
[[task]]
name = "server"
command = "echo two"
"#,
)
.unwrap();
assert!(validate_for_path(&duplicate, &path).is_err());
}
#[test]
fn validates_dependencies_and_start_delay() {
let temp = tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE);
let valid: Config = toml::from_str(
r#"
[[task]]
name = "server"
command = "echo server"
[[task]]
name = "web"
command = "echo web"
depends_on = ["server"]
start_delay = "3s"
"#,
)
.unwrap();
validate_for_path(&valid, &path).unwrap();
assert_eq!(
parse_start_delay("500ms").unwrap(),
Duration::from_millis(500)
);
assert_eq!(parse_start_delay("2").unwrap(), Duration::from_secs(2));
}
#[test]
fn rejects_unknown_dependency_and_cycles() {
let temp = tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE);
let unknown: Config = toml::from_str(
r#"
[[task]]
name = "web"
command = "echo web"
depends_on = ["server"]
"#,
)
.unwrap();
assert!(validate_for_path(&unknown, &path).is_err());
let cycle: Config = toml::from_str(
r#"
[[task]]
name = "api"
command = "echo api"
depends_on = ["web"]
[[task]]
name = "web"
command = "echo web"
depends_on = ["api"]
"#,
)
.unwrap();
let error = validate_for_path(&cycle, &path).unwrap_err().to_string();
assert!(error.contains("dependency cycle"));
}
#[test]
fn defaults_to_alt_j_leader() {
let config: Config = toml::from_str(
r#"
[[task]]
name = "server"
command = "echo ready"
"#,
)
.unwrap();
assert_eq!(config.settings.leader, Leader::AltJ);
assert_eq!(config.settings.multi_click_ms, DEFAULT_MULTI_CLICK_MS);
}
#[test]
fn parses_alt_backtick_leader() {
let config: Config = toml::from_str(
r#"
[settings]
leader = "alt-backtick"
[[task]]
name = "server"
command = "echo ready"
"#,
)
.unwrap();
assert_eq!(config.settings.leader, Leader::AltBacktick);
assert_eq!(config.settings.leader.label(), "Alt-`");
}
#[test]
fn validates_multi_click_timing() {
let temp = tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE);
let config: Config = toml::from_str(
r#"
[settings]
multi_click_ms = 400
[[task]]
name = "server"
command = "echo ready"
"#,
)
.unwrap();
validate_for_path(&config, &path).unwrap();
let invalid: Config = toml::from_str(
r#"
[settings]
multi_click_ms = 20
[[task]]
name = "server"
command = "echo ready"
"#,
)
.unwrap();
assert!(validate_for_path(&invalid, &path).is_err());
}
}