Skip to main content

smux/
templates.rs

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