use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::config::{Pane, PodConfig};
use super::error::{PodError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PaneTemplate {
#[serde(default)]
pub default_command: Option<String>,
#[serde(default)]
pub on_pane_dead: Vec<String>,
}
pub const TEMPLATE_DIR_ENV: &str = "FORGE_POD_TEMPLATE_DIR";
pub fn template_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os(TEMPLATE_DIR_ENV)
&& !dir.is_empty()
{
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|h| {
PathBuf::from(h)
.join(".config")
.join("cosmux")
.join("templates")
})
}
pub fn load_template(name: &str) -> Result<Option<PaneTemplate>> {
let Some(dir) = template_dir() else {
return Ok(None);
};
let path = dir.join(format!("{name}.yaml"));
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)?;
let tpl: PaneTemplate = serde_yaml::from_str(&raw).map_err(|e| PodError::InvalidYaml {
path: path.display().to_string(),
source: e,
})?;
Ok(Some(tpl))
}
pub fn apply_templates(pod: &mut PodConfig) -> Result<()> {
let pod_template = pod.template.clone();
for window in &mut pod.windows {
for pane in &mut window.panes {
let template_name = pane.template.as_deref().or(pod_template.as_deref());
let Some(name) = template_name else {
continue;
};
let Some(tpl) = load_template(name)? else {
tracing::warn!(
"template '{name}' referenced but not found in ~/.config/cosmux/templates/"
);
continue;
};
merge_pane_template(pane, &tpl);
}
}
Ok(())
}
pub fn merge_pane_template(pane: &mut Pane, tpl: &PaneTemplate) {
if pane.command.is_none() {
pane.command = tpl.default_command.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pane(command: Option<&str>, template: Option<&str>) -> Pane {
Pane {
cwd: None,
command: command.map(String::from),
template: template.map(String::from),
task: None,
}
}
#[test]
fn template_fills_an_empty_command() {
let mut p = pane(None, Some("claude"));
merge_pane_template(
&mut p,
&PaneTemplate {
default_command: Some("ccyolo".into()),
on_pane_dead: vec![],
},
);
assert_eq!(p.command.as_deref(), Some("ccyolo"));
}
#[test]
fn explicit_command_wins_over_the_template() {
let mut p = pane(Some("explicit"), Some("claude"));
merge_pane_template(
&mut p,
&PaneTemplate {
default_command: Some("ccyolo".into()),
on_pane_dead: vec![],
},
);
assert_eq!(p.command.as_deref(), Some("explicit"));
}
#[test]
fn missing_template_is_a_warning_not_an_error() {
let previous = std::env::var_os(TEMPLATE_DIR_ENV);
let empty = std::env::temp_dir().join(format!("forge-pod-tpl-{}", std::process::id()));
std::fs::create_dir_all(&empty).unwrap();
unsafe { std::env::set_var(TEMPLATE_DIR_ENV, &empty) };
let mut pod = PodConfig::parse(
"name: p\ntemplate: nonexistent\nwindows:\n - name: w\n panes:\n - {}\n",
"test",
)
.unwrap();
let result = apply_templates(&mut pod);
match previous {
Some(v) => unsafe { std::env::set_var(TEMPLATE_DIR_ENV, v) },
None => unsafe { std::env::remove_var(TEMPLATE_DIR_ENV) },
}
let _ = std::fs::remove_dir_all(&empty);
assert!(result.is_ok(), "missing template must not fail the pod");
assert_eq!(pod.windows[0].panes[0].command, None);
}
#[test]
fn pane_template_overrides_the_pod_template() {
let pod = PodConfig::parse(
"name: p\ntemplate: pod-level\nwindows:\n - name: w\n panes:\n - template: pane-level\n",
"test",
)
.unwrap();
assert_eq!(pod.template.as_deref(), Some("pod-level"));
assert_eq!(
pod.windows[0].panes[0].template.as_deref(),
Some("pane-level")
);
}
}