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}