Skip to main content

smux/
templates.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, bail};
4
5use crate::config::{Pane, Template, Window};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SessionPlan {
9    pub session_name: String,
10    pub windows: Vec<WindowPlan>,
11    pub startup_window: String,
12    pub startup_pane: usize,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct WindowPlan {
17    pub name: String,
18    pub cwd: PathBuf,
19    pub pre_command: Option<String>,
20    pub command: Option<String>,
21    pub layout: Option<String>,
22    pub synchronize: bool,
23    pub panes: Vec<PanePlan>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PanePlan {
28    pub layout: Option<PaneLayout>,
29    pub cwd: PathBuf,
30    pub command: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PaneLayout {
35    pub position: PanePosition,
36    pub size: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PanePosition {
41    Right,
42    Left,
43    Bottom,
44    Top,
45}
46
47pub fn fallback_template() -> Template {
48    Template {
49        root: None,
50        startup_window: Some("main".to_owned()),
51        startup_pane: Some(0),
52        windows: vec![Window {
53            name: "main".to_owned(),
54            cwd: None,
55            pre_command: None,
56            command: None,
57            layout: None,
58            synchronize: false,
59            panes: None,
60        }],
61    }
62}
63
64pub fn build_session_plan(
65    session_name: &str,
66    root: &Path,
67    template: &Template,
68) -> Result<SessionPlan> {
69    if template.windows.is_empty() {
70        bail!("template must contain at least one window");
71    }
72
73    let template_root = resolve_root(root, template.root.as_deref())?;
74    let mut windows = Vec::with_capacity(template.windows.len());
75
76    for window in &template.windows {
77        windows.push(build_window_plan(&template_root, root, window)?);
78    }
79
80    let startup_window = template
81        .startup_window
82        .clone()
83        .unwrap_or_else(|| template.windows[0].name.clone());
84
85    let startup_pane = resolve_startup_pane(template, &windows, &startup_window)?;
86
87    Ok(SessionPlan {
88        session_name: session_name.to_owned(),
89        windows,
90        startup_window,
91        startup_pane,
92    })
93}
94
95fn build_window_plan(
96    template_root: &Path,
97    session_root: &Path,
98    window: &Window,
99) -> Result<WindowPlan> {
100    let cwd = resolve_root(template_root, window.cwd.as_deref())?;
101    let panes = match &window.panes {
102        Some(panes) => panes
103            .iter()
104            .map(|pane| build_pane_plan(template_root, session_root, &cwd, pane))
105            .collect::<Result<Vec<_>>>()?,
106        None => Vec::new(),
107    };
108
109    Ok(WindowPlan {
110        name: window.name.clone(),
111        cwd,
112        pre_command: window.pre_command.clone(),
113        command: window.command.clone(),
114        layout: window.layout.clone(),
115        synchronize: window.synchronize,
116        panes,
117    })
118}
119
120fn build_pane_plan(
121    template_root: &Path,
122    session_root: &Path,
123    window_root: &Path,
124    pane: &Pane,
125) -> Result<PanePlan> {
126    let cwd = if let Some(cwd) = &pane.cwd {
127        resolve_relative(session_root, template_root, window_root, cwd)?
128    } else {
129        window_root.to_path_buf()
130    };
131
132    Ok(PanePlan {
133        layout: parse_pane_layout(pane.layout.as_deref())?,
134        cwd,
135        command: pane.command.clone(),
136    })
137}
138
139fn parse_pane_layout(layout: Option<&str>) -> Result<Option<PaneLayout>> {
140    let Some(layout) = layout else {
141        return Ok(None);
142    };
143
144    let mut parts = layout.split_whitespace();
145    let position = match parts.next() {
146        Some("right") => PanePosition::Right,
147        Some("left") => PanePosition::Left,
148        Some("bottom") => PanePosition::Bottom,
149        Some("top") => PanePosition::Top,
150        Some(other) => bail!("unknown pane layout position: {other}"),
151        None => bail!("pane layout cannot be empty"),
152    };
153
154    let size = parts.next().map(ToOwned::to_owned);
155    if parts.next().is_some() {
156        bail!("pane layout must be in the form '<position>' or '<position> <size>'");
157    }
158
159    Ok(Some(PaneLayout { position, size }))
160}
161
162fn resolve_root(session_root: &Path, root: Option<&str>) -> Result<PathBuf> {
163    match root {
164        Some(root) => resolve_relative(session_root, session_root, session_root, root),
165        None => Ok(session_root.to_path_buf()),
166    }
167}
168
169fn resolve_startup_pane(
170    template: &Template,
171    windows: &[WindowPlan],
172    startup_window: &str,
173) -> Result<usize> {
174    let startup_pane = template.startup_pane.unwrap_or(0);
175    let window = windows
176        .iter()
177        .find(|window| window.name == startup_window)
178        .ok_or_else(|| anyhow::anyhow!("startup window \"{startup_window}\" was not found"))?;
179
180    let pane_count = if window.panes.is_empty() {
181        1
182    } else {
183        window.panes.len()
184    };
185
186    if startup_pane >= pane_count {
187        bail!(
188            "startup_pane {} is out of range for window \"{}\" with {} pane(s)",
189            startup_pane,
190            startup_window,
191            pane_count
192        );
193    }
194
195    Ok(startup_pane)
196}
197
198fn resolve_relative(
199    session_root: &Path,
200    template_root: &Path,
201    window_root: &Path,
202    value: &str,
203) -> Result<PathBuf> {
204    let path = Path::new(value);
205    let base = if path.is_absolute() {
206        PathBuf::new()
207    } else if value.starts_with("./") || value.starts_with("../") {
208        window_root.to_path_buf()
209    } else {
210        template_root.to_path_buf()
211    };
212
213    let resolved = if path.is_absolute() {
214        path.to_path_buf()
215    } else if base.as_os_str().is_empty() {
216        session_root.join(path)
217    } else {
218        base.join(path)
219    };
220
221    Ok(resolved)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::{build_session_plan, fallback_template};
227    use crate::config::{Pane, Template, Window};
228    use anyhow::Result;
229    use std::path::Path;
230
231    #[test]
232    fn fallback_template_has_main_window() {
233        let template = fallback_template();
234        assert_eq!(template.windows.len(), 1);
235        assert_eq!(template.windows[0].name, "main");
236    }
237
238    #[test]
239    fn builds_window_and_pane_plan() -> Result<()> {
240        let template = Template {
241            root: Some("workspace".to_owned()),
242            startup_window: Some("editor".to_owned()),
243            startup_pane: Some(0),
244            windows: vec![
245                Window {
246                    name: "editor".to_owned(),
247                    cwd: Some("app".to_owned()),
248                    pre_command: Some("source .venv/bin/activate".to_owned()),
249                    command: Some("nvim".to_owned()),
250                    layout: None,
251                    synchronize: false,
252                    panes: None,
253                },
254                Window {
255                    name: "run".to_owned(),
256                    cwd: None,
257                    pre_command: None,
258                    command: None,
259                    layout: Some("main-horizontal".to_owned()),
260                    synchronize: true,
261                    panes: Some(vec![
262                        Pane {
263                            layout: None,
264                            cwd: None,
265                            command: Some("cargo run".to_owned()),
266                        },
267                        Pane {
268                            layout: Some("right 30%".to_owned()),
269                            cwd: Some("./server".to_owned()),
270                            command: Some("cargo test".to_owned()),
271                        },
272                    ]),
273                },
274            ],
275        };
276
277        let plan = build_session_plan("demo", Path::new("/tmp/demo"), &template)?;
278        assert_eq!(plan.startup_window, "editor");
279        assert_eq!(plan.startup_pane, 0);
280        assert_eq!(plan.windows.len(), 2);
281        assert_eq!(plan.windows[0].cwd, Path::new("/tmp/demo/workspace/app"));
282        assert_eq!(
283            plan.windows[0].pre_command.as_deref(),
284            Some("source .venv/bin/activate")
285        );
286        assert!(plan.windows[1].synchronize);
287        assert_eq!(
288            plan.windows[1].panes[1].cwd,
289            Path::new("/tmp/demo/workspace/server")
290        );
291        Ok(())
292    }
293
294    #[test]
295    fn rejects_startup_pane_out_of_range() {
296        let template = Template {
297            root: None,
298            startup_window: Some("main".to_owned()),
299            startup_pane: Some(2),
300            windows: vec![Window {
301                name: "main".to_owned(),
302                cwd: None,
303                pre_command: None,
304                command: None,
305                layout: None,
306                synchronize: false,
307                panes: Some(vec![Pane {
308                    layout: None,
309                    cwd: None,
310                    command: None,
311                }]),
312            }],
313        };
314
315        let error = build_session_plan("demo", Path::new("/tmp/demo"), &template)
316            .expect_err("startup pane should be validated");
317        assert!(error.to_string().contains("startup_pane"));
318    }
319
320    #[test]
321    fn rejects_invalid_pane_layout_string() {
322        let template = Template {
323            root: None,
324            startup_window: Some("main".to_owned()),
325            startup_pane: Some(0),
326            windows: vec![Window {
327                name: "main".to_owned(),
328                cwd: None,
329                pre_command: None,
330                command: None,
331                layout: None,
332                synchronize: false,
333                panes: Some(vec![
334                    Pane {
335                        layout: None,
336                        cwd: None,
337                        command: None,
338                    },
339                    Pane {
340                        layout: Some("diagonal 30%".to_owned()),
341                        cwd: None,
342                        command: None,
343                    },
344                ]),
345            }],
346        };
347
348        let error = build_session_plan("demo", Path::new("/tmp/demo"), &template)
349            .expect_err("pane layout should be validated");
350        assert!(error.to_string().contains("unknown pane layout position"));
351    }
352}