use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::env_interp::{self, EnvContext, ExpandError, Redacted, SecretsLoadError};
use crate::hooks::{Hook, RawHook};
use crate::layout::Layout;
use crate::pane::PaneLaunch;
#[derive(Deserialize)]
pub struct ProjectConfig {
pub workspace: Option<WorkspaceSection>,
#[serde(default)]
pub pane: Vec<PaneSection>,
#[serde(default)]
pub hooks: Vec<RawHook>,
}
#[derive(Deserialize)]
pub struct WorkspaceSection {
pub layout: Option<String>,
pub rows: Option<usize>,
pub cols: Option<usize>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
#[default]
Never,
OnFailure,
Always,
}
#[derive(Deserialize)]
pub struct PaneSection {
pub command: Option<String>,
pub cwd: Option<String>,
pub name: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub restart: RestartPolicy,
pub shell: Option<String>,
pub persist_scrollback: Option<bool>,
}
pub struct ResolvedProject {
pub layout: Layout,
pub launches: HashMap<usize, PaneLaunch>,
pub cwds: HashMap<usize, PathBuf>,
pub names: HashMap<usize, String>,
pub envs: HashMap<usize, HashMap<String, String>>,
pub restarts: HashMap<usize, RestartPolicy>,
pub shells: HashMap<usize, String>,
#[allow(dead_code)]
pub persist_scrollback: HashMap<usize, bool>,
#[allow(dead_code)]
pub hooks: Vec<Hook>,
}
pub fn load_project() -> Option<Result<ResolvedProject, String>> {
let path = Path::new(".ezpn.toml");
if !path.exists() {
return None;
}
Some(load_project_from(path))
}
fn load_project_from(path: &Path) -> Result<ResolvedProject, String> {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let config: ProjectConfig =
toml::from_str(&contents).map_err(|e| format!("parse error in {}: {e}", path.display()))?;
let layout = resolve_layout(&config)?;
let pane_ids = layout.pane_ids();
let mut launches = HashMap::new();
let mut cwds = HashMap::new();
let mut names = HashMap::new();
let mut envs = HashMap::new();
let mut restarts = HashMap::new();
let mut shells = HashMap::new();
let mut persist_scrollback = HashMap::new();
let base_dir = path
.parent()
.unwrap_or(Path::new("."))
.canonicalize()
.unwrap_or_else(|_| PathBuf::from("."));
let dotenv = env_interp::load_dotenv(&base_dir).map_err(|e| {
format!("loading .env.local: {e}")
})?;
let secrets_path = env_interp::default_secrets_path();
let secrets: HashMap<String, Redacted<String>> = env_interp::load_secrets(&secrets_path)
.map_err(|e: SecretsLoadError| format!("loading secrets: {e}"))?;
let base_ctx = EnvContext::build(dotenv, secrets);
for (i, pid) in pane_ids.iter().enumerate() {
if let Some(section) = config.pane.get(i) {
let mut resolved_env: HashMap<String, String> = HashMap::new();
for (k, v) in §ion.env {
let expanded = env_interp::expand(v, &base_ctx)
.map_err(|e| format_expand_err("env", k, &e))?;
resolved_env.insert(k.clone(), expanded);
}
let pane_ctx = base_ctx.with_pane(resolved_env.clone());
if let Some(cmd) = §ion.command {
let expanded = env_interp::expand(cmd, &pane_ctx)
.map_err(|e| format_expand_err("command", "", &e))?;
launches.insert(*pid, PaneLaunch::Command(expanded));
} else {
launches.insert(*pid, PaneLaunch::Shell);
}
if let Some(cwd) = §ion.cwd {
let expanded = env_interp::expand(cwd, &pane_ctx)
.map_err(|e| format_expand_err("cwd", "", &e))?;
let resolved = base_dir.join(expanded);
cwds.insert(*pid, resolved);
}
if let Some(name) = §ion.name {
let expanded = env_interp::expand(name, &pane_ctx)
.map_err(|e| format_expand_err("name", "", &e))?;
names.insert(*pid, expanded);
}
if !resolved_env.is_empty() {
envs.insert(*pid, resolved_env);
}
if section.restart != RestartPolicy::Never {
restarts.insert(*pid, section.restart.clone());
}
if let Some(shell) = §ion.shell {
let expanded = env_interp::expand(shell, &pane_ctx)
.map_err(|e| format_expand_err("shell", "", &e))?;
shells.insert(*pid, expanded);
}
if let Some(flag) = section.persist_scrollback {
persist_scrollback.insert(*pid, flag);
}
} else {
launches.insert(*pid, PaneLaunch::Shell);
}
}
let mut hooks = Vec::with_capacity(config.hooks.len());
for (i, raw) in config.hooks.into_iter().enumerate() {
let hook = Hook::from_raw(raw)
.map_err(|e| format!("[[hooks]][{i}] in {}: {e}", path.display()))?;
hooks.push(hook);
}
Ok(ResolvedProject {
layout,
launches,
cwds,
names,
envs,
restarts,
shells,
persist_scrollback,
hooks,
})
}
fn resolve_layout(config: &ProjectConfig) -> Result<Layout, String> {
let ws = config.workspace.as_ref();
if let Some(ws) = ws {
if let Some(spec) = &ws.layout {
return Layout::from_spec(spec);
}
let rows = ws.rows.unwrap_or(1);
let cols = ws.cols.unwrap_or(2);
if rows == 0 || cols == 0 {
return Err("rows and cols must be >= 1".into());
}
if rows * cols > 100 {
return Err(format!(
"maximum 100 panes (got {}x{}={})",
rows,
cols,
rows * cols
));
}
return Ok(Layout::from_grid(rows, cols));
}
let pane_count = config.pane.len().max(2);
Ok(Layout::from_grid(1, pane_count))
}
fn format_expand_err(field: &str, key: &str, err: &ExpandError) -> String {
if key.is_empty() {
format!("interpolation error in `{field}`: {err}")
} else {
format!("interpolation error in `{field}.{key}`: {err}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_toml() {
let toml_str = r#"
[workspace]
layout = "7:3"
[[pane]]
command = "cargo test"
[[pane]]
command = "npm dev"
cwd = "./frontend"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.workspace.as_ref().unwrap().layout.as_deref(),
Some("7:3")
);
assert_eq!(config.pane.len(), 2);
assert_eq!(config.pane[0].command.as_deref(), Some("cargo test"));
assert_eq!(config.pane[1].cwd.as_deref(), Some("./frontend"));
}
#[test]
fn parse_grid_toml() {
let toml_str = r#"
[workspace]
rows = 2
cols = 3
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
let ws = config.workspace.unwrap();
assert_eq!(ws.rows, Some(2));
assert_eq!(ws.cols, Some(3));
}
#[test]
fn parse_minimal_toml() {
let toml_str = r#"
[[pane]]
command = "make watch"
[[pane]]
command = "make test"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert!(config.workspace.is_none());
assert_eq!(config.pane.len(), 2);
}
#[test]
fn parse_enhanced_fields() {
let toml_str = r#"
[workspace]
layout = "1:1"
[[pane]]
name = "server"
command = "npm run dev"
cwd = "./frontend"
shell = "/bin/bash"
restart = "on_failure"
[pane.env]
NODE_ENV = "development"
PORT = "3000"
[[pane]]
name = "tests"
command = "cargo watch -x test"
restart = "always"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.pane.len(), 2);
let p0 = &config.pane[0];
assert_eq!(p0.name.as_deref(), Some("server"));
assert_eq!(p0.shell.as_deref(), Some("/bin/bash"));
assert_eq!(p0.restart, RestartPolicy::OnFailure);
assert_eq!(
p0.env.get("NODE_ENV").map(|s| s.as_str()),
Some("development")
);
assert_eq!(p0.env.get("PORT").map(|s| s.as_str()), Some("3000"));
let p1 = &config.pane[1];
assert_eq!(p1.name.as_deref(), Some("tests"));
assert_eq!(p1.restart, RestartPolicy::Always);
assert!(p1.env.is_empty());
}
#[test]
fn parse_default_restart() {
let toml_str = r#"
[[pane]]
command = "echo hello"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.pane[0].restart, RestartPolicy::Never);
}
#[test]
fn parse_persist_scrollback_override() {
let toml_str = r#"
[[pane]]
command = "tail -F /var/log/syslog"
persist_scrollback = true
[[pane]]
command = "htop"
persist_scrollback = false
[[pane]]
command = "echo neutral"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.pane[0].persist_scrollback, Some(true));
assert_eq!(config.pane[1].persist_scrollback, Some(false));
assert_eq!(config.pane[2].persist_scrollback, None);
}
#[test]
fn resolved_persist_scrollback_only_records_explicit_overrides() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
let toml_str = r#"
[workspace]
layout = "1:1"
[[pane]]
command = "echo a"
persist_scrollback = true
[[pane]]
command = "echo b"
"#;
let path = write_project(tmp.path(), toml_str);
let resolved = load_project_from(&path).expect("load");
assert_eq!(resolved.persist_scrollback.len(), 1);
assert_eq!(resolved.persist_scrollback.values().next(), Some(&true));
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
use crate::pane::PaneLaunch;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn write_project(dir: &std::path::Path, body: &str) -> std::path::PathBuf {
let path = dir.join(".ezpn.toml");
std::fs::write(&path, body).unwrap();
path
}
fn isolate_secrets_dir(dir: &std::path::Path) {
std::env::set_var("EZPN_TEST_SECRETS_DIR", dir);
}
#[test]
fn command_with_dollar_var_resolves() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
std::env::set_var("EZPN_TEST_EDITOR_VAR", "nvim");
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "$EZPN_TEST_EDITOR_VAR ."
"#;
let path = write_project(tmp.path(), toml);
let resolved = load_project_from(&path).expect("load");
let (_, launch) = resolved.launches.iter().next().expect("one pane");
match launch {
PaneLaunch::Command(cmd) => assert_eq!(cmd, "nvim ."),
other => panic!("expected Command, got {other:?}"),
}
std::env::remove_var("EZPN_TEST_EDITOR_VAR");
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[test]
fn command_with_default_uses_default_when_unset() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
std::env::remove_var("EZPN_TEST_PORT_VAR");
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "${EZPN_TEST_PORT_VAR:-3000}"
"#;
let path = write_project(tmp.path(), toml);
let resolved = load_project_from(&path).expect("load");
let (_, launch) = resolved.launches.iter().next().unwrap();
match launch {
PaneLaunch::Command(cmd) => assert_eq!(cmd, "3000"),
other => panic!("expected Command, got {other:?}"),
}
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[test]
fn dotenv_overrides_process_env_at_project_level() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
std::env::set_var("EZPN_TEST_HOST_VAR", "from-process");
std::fs::write(
tmp.path().join(".env.local"),
"EZPN_TEST_HOST_VAR=from-dotenv\n",
)
.unwrap();
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "${EZPN_TEST_HOST_VAR}"
"#;
let path = write_project(tmp.path(), toml);
let resolved = load_project_from(&path).expect("load");
let (_, launch) = resolved.launches.iter().next().unwrap();
match launch {
PaneLaunch::Command(cmd) => assert_eq!(cmd, "from-dotenv"),
other => panic!("expected Command, got {other:?}"),
}
std::env::remove_var("EZPN_TEST_HOST_VAR");
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[cfg(unix)]
#[test]
fn secret_ref_resolves_from_0600_secrets_file() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let secrets_dir = tmp.path().join("ezpn");
std::fs::create_dir_all(&secrets_dir).unwrap();
let secrets_path = secrets_dir.join("secrets.toml");
std::fs::write(&secrets_path, "DB_PASSWORD = \"hunter2\"\n").unwrap();
std::fs::set_permissions(&secrets_path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::env::set_var("EZPN_TEST_SECRETS_DIR", tmp.path());
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "psql --password=${secret:DB_PASSWORD}"
"#;
let path = write_project(tmp.path(), toml);
let resolved = load_project_from(&path).expect("load");
let (_, launch) = resolved.launches.iter().next().unwrap();
match launch {
PaneLaunch::Command(cmd) => assert_eq!(cmd, "psql --password=hunter2"),
other => panic!("expected Command, got {other:?}"),
}
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[cfg(unix)]
#[test]
fn secret_file_with_mode_0644_refused_at_load() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let secrets_dir = tmp.path().join("ezpn");
std::fs::create_dir_all(&secrets_dir).unwrap();
let secrets_path = secrets_dir.join("secrets.toml");
std::fs::write(&secrets_path, "DB_PASSWORD = \"hunter2\"\n").unwrap();
std::fs::set_permissions(&secrets_path, std::fs::Permissions::from_mode(0o644)).unwrap();
std::env::set_var("EZPN_TEST_SECRETS_DIR", tmp.path());
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "echo ok"
"#;
let path = write_project(tmp.path(), toml);
let err = match load_project_from(&path) {
Ok(_) => panic!("must refuse 0644 secrets"),
Err(e) => e,
};
assert!(
err.contains("0600"),
"error should mention required mode: {err}"
);
assert!(
!err.contains("hunter2"),
"secret value leaked into error: {err}"
);
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[cfg(unix)]
#[test]
fn secrets_never_appear_in_resolved_project_debug() {
use std::os::unix::fs::PermissionsExt;
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let secrets_dir = tmp.path().join("ezpn");
std::fs::create_dir_all(&secrets_dir).unwrap();
let secrets_path = secrets_dir.join("secrets.toml");
std::fs::write(&secrets_path, "TOKEN = \"do-not-print-me\"\n").unwrap();
std::fs::set_permissions(&secrets_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let secrets = crate::env_interp::load_secrets(&secrets_path).expect("load secrets");
let dbg = format!("{:?}", secrets);
assert!(
!dbg.contains("do-not-print-me"),
"secrets HashMap Debug leaked value: {dbg}"
);
}
#[test]
fn required_var_unset_errors_with_offending_name() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
std::env::remove_var("EZPN_TEST_REQUIRED_VAR");
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "${EZPN_TEST_REQUIRED_VAR:?must be set}"
"#;
let path = write_project(tmp.path(), toml);
let err = match load_project_from(&path) {
Ok(_) => panic!("must error on unset required var"),
Err(e) => e,
};
assert!(
err.contains("EZPN_TEST_REQUIRED_VAR"),
"error missing var name: {err}"
);
assert!(err.contains("command"), "error missing field name: {err}");
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[test]
fn per_pane_env_value_itself_is_expanded() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
std::env::set_var("EZPN_TEST_BASE_URL", "https://api.example.com");
let toml = r#"
[workspace]
layout = "1"
[[pane]]
command = "echo ${API_URL}"
[pane.env]
API_URL = "${EZPN_TEST_BASE_URL}/v1"
"#;
let path = write_project(tmp.path(), toml);
let resolved = load_project_from(&path).expect("load");
let (_, env) = resolved.envs.iter().next().expect("one pane env map");
assert_eq!(
env.get("API_URL").map(|s| s.as_str()),
Some("https://api.example.com/v1")
);
let (_, launch) = resolved.launches.iter().next().unwrap();
match launch {
PaneLaunch::Command(cmd) => assert_eq!(cmd, "echo https://api.example.com/v1"),
other => panic!("expected Command, got {other:?}"),
}
std::env::remove_var("EZPN_TEST_BASE_URL");
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[test]
fn project_hooks_validated_on_load() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
let toml_str = r#"
[[pane]]
command = "echo a"
[[pane]]
command = "echo b"
[[hooks]]
event = "after_pane_exit"
exec = ["true"]
"#;
let path = write_project(tmp.path(), toml_str);
let resolved = load_project_from(&path).expect("load");
assert_eq!(resolved.hooks.len(), 1);
assert_eq!(
resolved.hooks[0].event,
crate::hooks::HookEvent::AfterPaneExit
);
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
#[test]
fn project_hooks_unknown_event_refuses_load() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
isolate_secrets_dir(tmp.path());
let toml_str = r#"
[[pane]]
command = "echo a"
[[hooks]]
event = "after_typo"
exec = ["true"]
"#;
let path = write_project(tmp.path(), toml_str);
let err = match load_project_from(&path) {
Ok(_) => panic!("must reject unknown hook event"),
Err(e) => e,
};
assert!(err.contains("unknown hook event"), "{err}");
std::env::remove_var("EZPN_TEST_SECRETS_DIR");
}
}