1use std::collections::{HashMap, HashSet};
2
3use toml::{Table, Value};
4
5use crate::builtins;
6
7#[derive(Debug, thiserror::Error, PartialEq, Eq)]
8#[error("{0}")]
9pub struct LayoutError(pub String);
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SplitDirection {
13 Row, Column, }
16
17impl SplitDirection {
18 const ALL: &[(&str, SplitDirection)] = &[
19 ("row", SplitDirection::Row),
20 ("column", SplitDirection::Column),
21 ];
22
23 fn parse(raw: &str) -> Option<SplitDirection> {
24 Self::ALL
25 .iter()
26 .find(|(name, _)| *name == raw)
27 .map(|(_, direction)| *direction)
28 }
29
30 fn names() -> String {
31 Self::ALL
32 .iter()
33 .map(|(name, _)| *name)
34 .collect::<Vec<_>>()
35 .join(", ")
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct Pane {
41 pub command: Option<String>, pub builtin: Option<String>, pub args: Option<String>, pub focus: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Split {
49 pub direction: SplitDirection,
50 pub panes: Vec<Node>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Node {
55 Pane(Pane),
56 Split(Split),
57}
58
59pub fn default_layout() -> Node {
60 Node::Pane(Pane::default())
61}
62
63pub fn parse_layout(data: &Table) -> Result<Node, LayoutError> {
64 let node = parse_node(data)?;
65 if count_focus(&node) > 1 {
66 return Err(LayoutError("at most one pane may set focus".to_string()));
67 }
68 Ok(node)
69}
70
71fn unknown_keys(data: &Table, known: &[&str]) -> Option<String> {
72 let mut unknown: Vec<&str> = data
73 .keys()
74 .map(String::as_str)
75 .filter(|key| !known.contains(key))
76 .collect();
77 if unknown.is_empty() {
78 return None;
79 }
80 unknown.sort_unstable();
81 Some(unknown.join(", "))
82}
83
84pub(crate) fn coerce_string(value: &Value) -> String {
86 match value {
87 Value::String(s) => s.clone(),
88 other => other.to_string(),
89 }
90}
91
92fn truthy(value: &Value) -> bool {
94 match value {
95 Value::Boolean(b) => *b,
96 Value::Integer(i) => *i != 0,
97 Value::Float(f) => *f != 0.0,
98 Value::String(s) => !s.is_empty(),
99 Value::Array(a) => !a.is_empty(),
100 Value::Table(t) => !t.is_empty(),
101 Value::Datetime(_) => true,
102 }
103}
104
105fn parse_node(data: &Table) -> Result<Node, LayoutError> {
106 if data.contains_key("split") {
107 if let Some(unknown) = unknown_keys(data, &["split", "panes"]) {
108 return Err(LayoutError(format!("unknown split key(s): {unknown}")));
109 }
110 let raw = coerce_string(&data["split"]);
111 let Some(direction) = SplitDirection::parse(&raw) else {
112 return Err(LayoutError(format!(
113 "split must be one of {}, got '{raw}'",
114 SplitDirection::names()
115 )));
116 };
117 let panes = match data.get("panes") {
118 Some(Value::Array(panes)) if !panes.is_empty() => panes,
119 _ => {
120 return Err(LayoutError(
121 "a split needs a non-empty 'panes' list".to_string(),
122 ));
123 }
124 };
125 let panes = panes
126 .iter()
127 .map(|pane| match pane {
128 Value::Table(table) => parse_node(table),
129 _ => Err(LayoutError(
130 "each entry in 'panes' must be a table".to_string(),
131 )),
132 })
133 .collect::<Result<Vec<_>, _>>()?;
134 return Ok(Node::Split(Split { direction, panes }));
135 }
136 if let Some(unknown) = unknown_keys(data, &["command", "builtin", "args", "focus"]) {
137 return Err(LayoutError(format!("unknown pane key(s): {unknown}")));
138 }
139 if data.contains_key("command") && data.contains_key("builtin") {
140 return Err(LayoutError(
141 "a pane takes either a command or a builtin, not both".to_string(),
142 ));
143 }
144 let builtin = data.get("builtin").map(coerce_string);
145 if let Some(builtin) = &builtin
146 && !builtins::PANE_BUILTINS.contains(&builtin.as_str())
147 {
148 return Err(LayoutError(format!(
149 "unknown pane builtin '{builtin}' (supported: {})",
150 builtins::PANE_BUILTINS.join(", ")
151 )));
152 }
153 if data.contains_key("args") && !data.contains_key("builtin") {
154 return Err(LayoutError("pane args require a builtin".to_string()));
155 }
156 Ok(Node::Pane(Pane {
157 command: data.get("command").map(coerce_string),
158 builtin,
159 args: data.get("args").map(coerce_string),
160 focus: data.get("focus").is_some_and(truthy),
161 }))
162}
163
164fn count_focus(node: &Node) -> usize {
165 match node {
166 Node::Pane(pane) => pane.focus as usize,
167 Node::Split(split) => split.panes.iter().map(count_focus).sum(),
168 }
169}
170
171pub fn accepted_keys(node: &Node) -> HashSet<&'static str> {
173 match node {
174 Node::Pane(pane) => match &pane.builtin {
175 Some(builtin) => builtins::builtin_keys(builtin).iter().copied().collect(),
176 None => HashSet::new(),
177 },
178 Node::Split(split) => split.panes.iter().flat_map(accepted_keys).collect(),
179 }
180}
181
182pub fn resolve_layout(node: &Node, values: Option<&HashMap<String, String>>) -> Node {
187 match node {
188 Node::Pane(pane) => match &pane.builtin {
189 None => node.clone(),
190 Some(builtin) => Node::Pane(Pane {
191 command: Some(builtins::builtin_command(
192 builtin,
193 pane.args.as_deref(),
194 values,
195 )),
196 focus: pane.focus,
197 ..Pane::default()
198 }),
199 },
200 Node::Split(split) => Node::Split(Split {
201 direction: split.direction,
202 panes: split
203 .panes
204 .iter()
205 .map(|pane| resolve_layout(pane, values))
206 .collect(),
207 }),
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn parse(text: &str) -> Result<Node, LayoutError> {
216 let table: Table = toml::from_str(text).expect("valid TOML");
217 parse_layout(&table)
218 }
219
220 fn pane(command: &str) -> Node {
221 Node::Pane(Pane {
222 command: Some(command.to_string()),
223 ..Pane::default()
224 })
225 }
226
227 fn err(text: &str) -> String {
228 parse(text).expect_err("layout must be rejected").0
229 }
230
231 #[test]
232 fn empty_pane_gives_defaults() {
233 assert_eq!(parse("").unwrap(), Node::Pane(Pane::default()));
234 }
235
236 #[test]
237 fn pane_takes_command_and_focus() {
238 let node = parse("command = \"nvim\"\nfocus = true").unwrap();
239
240 assert_eq!(
241 node,
242 Node::Pane(Pane {
243 command: Some("nvim".to_string()),
244 focus: true,
245 ..Pane::default()
246 })
247 );
248 }
249
250 #[test]
251 fn split_holds_panes() {
252 let node = parse("split = \"row\"\npanes = [{}, { command = \"htop\" }]").unwrap();
253
254 assert_eq!(
255 node,
256 Node::Split(Split {
257 direction: SplitDirection::Row,
258 panes: vec![Node::Pane(Pane::default()), pane("htop")],
259 })
260 );
261 }
262
263 #[test]
264 fn splits_nest() {
265 let node = parse("split = \"column\"\npanes = [{ split = \"row\", panes = [{}, {}] }, {}]")
266 .unwrap();
267
268 assert_eq!(
269 node,
270 Node::Split(Split {
271 direction: SplitDirection::Column,
272 panes: vec![
273 Node::Split(Split {
274 direction: SplitDirection::Row,
275 panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
276 }),
277 Node::Pane(Pane::default()),
278 ],
279 })
280 );
281 }
282
283 #[test]
284 fn pane_takes_a_builtin_with_args() {
285 let node = parse("builtin = \"claude\"\nargs = \"--model opus\"\nfocus = true").unwrap();
286
287 assert_eq!(
288 node,
289 Node::Pane(Pane {
290 builtin: Some("claude".to_string()),
291 args: Some("--model opus".to_string()),
292 focus: true,
293 ..Pane::default()
294 })
295 );
296 }
297
298 #[test]
299 fn pane_rejects_command_and_builtin_together() {
300 assert!(
301 err("command = \"claude\"\nbuiltin = \"claude\"")
302 .contains("either a command or a builtin")
303 );
304 }
305
306 #[test]
307 fn pane_rejects_an_unknown_builtin() {
308 assert!(err("builtin = \"clod\"").contains("unknown pane builtin 'clod'"));
309 }
310
311 #[test]
312 fn pane_rejects_args_without_a_builtin() {
313 assert!(err("command = \"nvim\"\nargs = \"-R\"").contains("args require a builtin"));
314 }
315
316 #[test]
317 fn unknown_pane_key_rejected() {
318 assert!(err("comand = \"nvim\"").contains("unknown pane key"));
319 }
320
321 #[test]
322 fn unknown_split_key_rejected() {
323 assert!(err("split = \"row\"\npanes = [{}]\nfocus = true").contains("unknown split key"));
324 }
325
326 #[test]
327 fn unknown_direction_rejected() {
328 assert!(err("split = \"diagonal\"\npanes = [{}]").contains("split must be one of"));
329 }
330
331 #[test]
332 fn empty_panes_rejected() {
333 assert!(err("split = \"row\"\npanes = []").contains("non-empty 'panes'"));
334 }
335
336 #[test]
337 fn missing_panes_rejected() {
338 assert!(err("split = \"row\"").contains("non-empty 'panes'"));
339 }
340
341 #[test]
342 fn multiple_focus_rejected() {
343 assert!(
344 err("split = \"row\"\npanes = [{ focus = true }, { focus = true }]")
345 .contains("at most one pane")
346 );
347 }
348
349 fn builtin_pane(builtin: &str, args: Option<&str>, focus: bool) -> Node {
350 Node::Pane(Pane {
351 builtin: Some(builtin.to_string()),
352 args: args.map(str::to_string),
353 focus,
354 ..Pane::default()
355 })
356 }
357
358 #[test]
359 fn accepted_keys_collects_builtin_keys_across_the_tree() {
360 let node = Node::Split(Split {
361 direction: SplitDirection::Row,
362 panes: vec![
363 builtin_pane("claude", None, false),
364 pane("nvim"),
365 Node::Pane(Pane::default()),
366 ],
367 });
368
369 assert_eq!(accepted_keys(&node), HashSet::from(["prompt"]));
370 }
371
372 #[test]
373 fn accepted_keys_is_empty_without_builtins() {
374 assert_eq!(accepted_keys(&pane("claude")), HashSet::new());
375 }
376
377 const TRUST: &str = "ctx builtin claude trust";
378
379 fn values(pairs: &[(&str, &str)]) -> HashMap<String, String> {
380 pairs
381 .iter()
382 .map(|(k, v)| (k.to_string(), v.to_string()))
383 .collect()
384 }
385
386 fn focused_pane(command: &str) -> Node {
387 Node::Pane(Pane {
388 command: Some(command.to_string()),
389 focus: true,
390 ..Pane::default()
391 })
392 }
393
394 #[test]
395 fn resolve_claude_passes_the_prompt_as_one_word() {
396 let node = resolve_layout(
397 &builtin_pane("claude", None, true),
398 Some(&values(&[("prompt", "explore the bug")])),
399 );
400
401 let quoted_prompt = r#"'"'"'explore the bug'"'"'"#;
402 assert_eq!(
403 node,
404 focused_pane(&format!("sh -c '{TRUST}; exec claude {quoted_prompt}'"))
405 );
406 }
407
408 #[test]
409 fn resolve_claude_without_a_prompt() {
410 let node = resolve_layout(&builtin_pane("claude", None, false), Some(&values(&[])));
411
412 assert_eq!(node, pane(&format!("sh -c '{TRUST}; exec claude'")));
413 }
414
415 #[test]
416 fn resolve_claude_keeps_extra_args() {
417 let node = resolve_layout(
418 &builtin_pane("claude", Some("--model opus"), false),
419 Some(&values(&[])),
420 );
421
422 assert_eq!(
423 node,
424 pane(&format!("sh -c '{TRUST}; exec claude --model opus'"))
425 );
426 }
427
428 #[test]
429 fn resolve_claude_on_a_recreated_session_resumes() {
430 let node = resolve_layout(&builtin_pane("claude", None, false), None);
431
432 assert_eq!(
433 node,
434 pane(&format!("sh -c '{TRUST}; exec claude --continue'"))
435 );
436 }
437
438 #[test]
439 fn resolve_claude_on_a_recreated_session_keeps_extra_args() {
440 let node = resolve_layout(&builtin_pane("claude", Some("--model opus"), false), None);
441
442 assert_eq!(
443 node,
444 pane(&format!(
445 "sh -c '{TRUST}; exec claude --model opus --continue'"
446 ))
447 );
448 }
449
450 #[test]
451 fn resolve_leaves_command_panes_alone() {
452 let node = Node::Split(Split {
453 direction: SplitDirection::Column,
454 panes: vec![pane("nvim"), builtin_pane("claude", None, false)],
455 });
456
457 let resolved = resolve_layout(&node, Some(&values(&[("prompt", "x")])));
458
459 assert_eq!(
460 resolved,
461 Node::Split(Split {
462 direction: SplitDirection::Column,
463 panes: vec![
464 pane("nvim"),
465 pane(&format!("sh -c '{TRUST}; exec claude x'"))
466 ],
467 })
468 );
469 }
470}