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
165pub(crate) fn validate_pane_layout(layout: &str) -> Result<()> {
166    parse_pane_layout(Some(layout)).map(|_| ())
167}
168
169fn resolve_root(session_root: &Path, root: Option<&str>) -> Result<PathBuf> {
170    match root {
171        Some(root) => resolve_relative(session_root, session_root, session_root, root),
172        None => Ok(session_root.to_path_buf()),
173    }
174}
175
176fn resolve_startup_pane(
177    template: &Template,
178    windows: &[WindowPlan],
179    startup_window: &str,
180) -> Result<usize> {
181    let startup_pane = template.startup_pane.unwrap_or(0);
182    let window = windows
183        .iter()
184        .find(|window| window.name == startup_window)
185        .ok_or_else(|| anyhow::anyhow!("startup window \"{startup_window}\" was not found"))?;
186
187    let pane_count = if window.panes.is_empty() {
188        1
189    } else {
190        window.panes.len()
191    };
192
193    if startup_pane >= pane_count {
194        bail!(
195            "startup_pane {} is out of range for window \"{}\" with {} pane(s)",
196            startup_pane,
197            startup_window,
198            pane_count
199        );
200    }
201
202    Ok(startup_pane)
203}
204
205fn resolve_relative(
206    session_root: &Path,
207    template_root: &Path,
208    window_root: &Path,
209    value: &str,
210) -> Result<PathBuf> {
211    let expanded = util::expand_tilde_path(Path::new(value));
212    let path = expanded.as_path();
213    let base = if path.is_absolute() {
214        PathBuf::new()
215    } else if value.starts_with("./") || value.starts_with("../") {
216        window_root.to_path_buf()
217    } else {
218        template_root.to_path_buf()
219    };
220
221    let resolved = if path.is_absolute() {
222        path.to_path_buf()
223    } else if base.as_os_str().is_empty() {
224        session_root.join(path)
225    } else {
226        base.join(path)
227    };
228
229    Ok(resolved)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{build_session_plan, fallback_template};
235    use crate::config::{Pane, Template, Window};
236    use anyhow::Result;
237    use std::path::Path;
238    use std::sync::Mutex;
239
240    static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
241
242    #[test]
243    fn fallback_template_has_main_window() {
244        let template = fallback_template();
245        assert_eq!(template.windows.len(), 1);
246        assert_eq!(template.windows[0].name, "main");
247    }
248
249    #[test]
250    fn builds_window_and_pane_plan() -> Result<()> {
251        let template = Template {
252            root: Some("workspace".to_owned()),
253            startup_window: Some("editor".to_owned()),
254            startup_pane: Some(0),
255            windows: vec![
256                Window {
257                    name: "editor".to_owned(),
258                    cwd: Some("app".to_owned()),
259                    pre_command: Some("source .venv/bin/activate".to_owned()),
260                    command: Some("nvim".to_owned()),
261                    layout: None,
262                    synchronize: false,
263                    panes: None,
264                },
265                Window {
266                    name: "run".to_owned(),
267                    cwd: None,
268                    pre_command: None,
269                    command: None,
270                    layout: Some("main-horizontal".to_owned()),
271                    synchronize: true,
272                    panes: Some(vec![
273                        Pane {
274                            layout: None,
275                            cwd: None,
276                            command: Some("cargo run".to_owned()),
277                            zoom: false,
278                        },
279                        Pane {
280                            layout: Some("right 30%".to_owned()),
281                            cwd: Some("./server".to_owned()),
282                            command: Some("cargo test".to_owned()),
283                            zoom: false,
284                        },
285                    ]),
286                },
287            ],
288        };
289
290        let plan = build_session_plan("demo", Path::new("/tmp/demo"), &template)?;
291        assert_eq!(plan.startup_window, "editor");
292        assert_eq!(plan.startup_pane, 0);
293        assert_eq!(plan.windows.len(), 2);
294        assert_eq!(plan.windows[0].cwd, Path::new("/tmp/demo/workspace/app"));
295        assert_eq!(
296            plan.windows[0].pre_command.as_deref(),
297            Some("source .venv/bin/activate")
298        );
299        assert!(plan.windows[1].synchronize);
300        assert_eq!(
301            plan.windows[1].panes[1].cwd,
302            Path::new("/tmp/demo/workspace/server")
303        );
304        Ok(())
305    }
306
307    #[test]
308    fn rejects_startup_pane_out_of_range() {
309        let template = Template {
310            root: None,
311            startup_window: Some("main".to_owned()),
312            startup_pane: Some(2),
313            windows: vec![Window {
314                name: "main".to_owned(),
315                cwd: None,
316                pre_command: None,
317                command: None,
318                layout: None,
319                synchronize: false,
320                panes: Some(vec![Pane {
321                    layout: None,
322                    cwd: None,
323                    command: None,
324                    zoom: false,
325                }]),
326            }],
327        };
328
329        let error = build_session_plan("demo", Path::new("/tmp/demo"), &template)
330            .expect_err("startup pane should be validated");
331        assert!(error.to_string().contains("startup_pane"));
332    }
333
334    #[test]
335    fn rejects_invalid_pane_layout_string() {
336        let template = Template {
337            root: None,
338            startup_window: Some("main".to_owned()),
339            startup_pane: Some(0),
340            windows: vec![Window {
341                name: "main".to_owned(),
342                cwd: None,
343                pre_command: None,
344                command: None,
345                layout: None,
346                synchronize: false,
347                panes: Some(vec![
348                    Pane {
349                        layout: None,
350                        cwd: None,
351                        command: None,
352                        zoom: false,
353                    },
354                    Pane {
355                        layout: Some("diagonal 30%".to_owned()),
356                        cwd: None,
357                        command: None,
358                        zoom: false,
359                    },
360                ]),
361            }],
362        };
363
364        let error = build_session_plan("demo", Path::new("/tmp/demo"), &template)
365            .expect_err("pane layout should be validated");
366        assert!(error.to_string().contains("unknown pane layout position"));
367    }
368
369    #[test]
370    fn expands_tilde_window_and_pane_paths() -> Result<()> {
371        let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
372        unsafe {
373            std::env::set_var("HOME", "/Users/stefan");
374        }
375
376        let template = Template {
377            root: None,
378            startup_window: Some("main".to_owned()),
379            startup_pane: Some(0),
380            windows: vec![Window {
381                name: "main".to_owned(),
382                cwd: Some("~/Development/smux".to_owned()),
383                pre_command: None,
384                command: None,
385                layout: None,
386                synchronize: false,
387                panes: Some(vec![
388                    Pane {
389                        layout: None,
390                        cwd: None,
391                        command: None,
392                        zoom: false,
393                    },
394                    Pane {
395                        layout: Some("right".to_owned()),
396                        cwd: Some("~/Development/nixpkgs".to_owned()),
397                        command: None,
398                        zoom: false,
399                    },
400                ]),
401            }],
402        };
403
404        let plan = build_session_plan("demo", Path::new("/tmp/demo"), &template)?;
405        assert_eq!(
406            plan.windows[0].cwd,
407            Path::new("/Users/stefan/Development/smux")
408        );
409        assert_eq!(
410            plan.windows[0].panes[1].cwd,
411            Path::new("/Users/stefan/Development/nixpkgs")
412        );
413
414        unsafe {
415            std::env::remove_var("HOME");
416        }
417
418        Ok(())
419    }
420}