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}