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}