use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defterm")]
pub struct TermSpec {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub shell: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub placement: String,
#[serde(default)]
pub attach: String,
#[serde(default)]
pub effects: Vec<String>,
#[serde(default)]
pub keybind: String,
}
pub const KNOWN_PLACEMENTS: &[&str] = &[
"tab",
"split-horizontal",
"split-vertical",
"window",
];
#[must_use]
pub fn is_known_placement(name: &str) -> bool {
name.is_empty() || KNOWN_PLACEMENTS.iter().any(|p| *p == name)
}
impl TermSpec {
#[must_use]
pub fn env_pairs(&self) -> Vec<(String, String)> {
self.env
.iter()
.filter_map(|s| {
s.split_once('=').map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect()
}
#[must_use]
pub fn is_attach(&self) -> bool {
!self.attach.is_empty()
}
pub fn to_mcp_value(&self) -> serde_json::Value {
serde_json::json!({
"shell": self.shell,
"args": self.args,
"cwd": self.cwd,
"env": self.env_pairs().into_iter().collect::<std::collections::HashMap<_, _>>(),
"title": self.title,
"placement": self.placement,
"attach": self.attach,
"effects": self.effects,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_pairs_splits_on_equals() {
let s = TermSpec {
name: "x".into(),
env: vec![
"RUST_LOG=warn".into(),
"CARGO_TERM_COLOR=always".into(),
"NO_EQUALS_HERE".into(), "EMPTY=".into(),
],
..Default::default()
};
let pairs = s.env_pairs();
assert_eq!(pairs.len(), 3);
assert!(pairs.contains(&("RUST_LOG".to_string(), "warn".to_string())));
assert!(pairs.contains(&("EMPTY".to_string(), "".to_string())));
}
#[test]
fn known_placement_accepts_canonicals_plus_empty() {
assert!(is_known_placement(""));
for p in KNOWN_PLACEMENTS {
assert!(is_known_placement(p));
}
assert!(!is_known_placement("zigzag"));
}
#[test]
fn mcp_payload_shape_matches_mado_termspec() {
let s = TermSpec {
name: "dev".into(),
shell: "frost".into(),
args: vec!["-l".into()],
cwd: "~/code".into(),
env: vec!["RUST_LOG=info".into()],
title: "dev".into(),
placement: "split-horizontal".into(),
effects: vec!["cursor-glow".into()],
..Default::default()
};
let payload = s.to_mcp_value();
assert_eq!(payload["shell"], "frost");
assert_eq!(payload["placement"], "split-horizontal");
assert_eq!(payload["env"]["RUST_LOG"], "info");
assert!(payload["args"].is_array());
assert!(payload["effects"].is_array());
}
#[test]
fn attach_signals_existing_session() {
let s = TermSpec {
name: "x".into(),
attach: "pane-42".into(),
..Default::default()
};
assert!(s.is_attach());
let fresh = TermSpec {
name: "y".into(),
..Default::default()
};
assert!(!fresh.is_attach());
}
}
impl Default for TermSpec {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
shell: String::new(),
args: Vec::new(),
cwd: String::new(),
env: Vec::new(),
title: String::new(),
placement: String::new(),
attach: String::new(),
effects: Vec::new(),
keybind: String::new(),
}
}
}