1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6use crate::config;
7use crate::tmux::{PaneSnapshot, SessionSnapshot, Tmux, WindowSnapshot};
8use crate::util;
9
10#[derive(Debug, Clone, Eq, PartialEq)]
11pub struct ExportedProject {
12 pub path: String,
13 pub session_name: String,
14 pub startup_window: String,
15 pub startup_pane: usize,
16 pub windows: Vec<ExportedWindow>,
17}
18
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct ExportedWindow {
21 pub name: String,
22 pub cwd: Option<String>,
23 pub layout: Option<String>,
24 pub synchronize: bool,
25 pub panes: Option<Vec<ExportedPane>>,
26}
27
28#[derive(Debug, Clone, Eq, PartialEq)]
29pub struct ExportedPane {
30 pub layout: Option<String>,
31 pub cwd: Option<String>,
32}
33
34pub fn capture_project(
35 tmux: &Tmux,
36 session: &str,
37 path_override: Option<&Path>,
38) -> Result<ExportedProject> {
39 let snapshot = tmux.capture_session(session)?;
40 ExportedProject::from_snapshot(snapshot, path_override)
41}
42
43pub fn save_project(
44 tmux: &Tmux,
45 name: &str,
46 session: Option<&str>,
47 path_override: Option<&Path>,
48 stdout: bool,
49 force: bool,
50 config_path: Option<&Path>,
51) -> Result<Option<PathBuf>> {
52 let project_name = util::validated_project_name(name)?;
53 let session = resolve_source_session(tmux, session)?;
54 let project = capture_project(tmux, &session, path_override)?;
55 let toml = project.to_toml();
56
57 if stdout {
58 print!("{toml}");
59 return Ok(None);
60 }
61
62 let config_path = match config_path {
63 Some(path) => path.to_path_buf(),
64 None => config::default_config_path()?,
65 };
66 let project_dir = config::projects_dir_for_config_path(&config_path);
67 fs::create_dir_all(&project_dir).with_context(|| {
68 format!(
69 "failed to create project directory {}",
70 project_dir.display()
71 )
72 })?;
73
74 let destination = project_dir.join(format!("{project_name}.toml"));
75 if destination.exists() && !force {
76 bail!(
77 "project already exists at {}; pass --force to overwrite",
78 destination.display()
79 );
80 }
81
82 fs::write(&destination, toml)
83 .with_context(|| format!("failed to write project {}", destination.display()))?;
84
85 Ok(Some(destination))
86}
87
88fn resolve_source_session(tmux: &Tmux, session: Option<&str>) -> Result<String> {
89 match session {
90 Some(session) => {
91 let session = util::validated_session_name(session)?;
92 tmux.ensure_session_exists(&session)?;
93 Ok(session)
94 }
95 None if util::inside_tmux() => tmux
96 .current_session()?
97 .context("could not determine current tmux session"),
98 None => bail!("--session is required outside tmux"),
99 }
100}
101
102impl ExportedProject {
103 fn from_snapshot(snapshot: SessionSnapshot, path_override: Option<&Path>) -> Result<Self> {
104 let path = match path_override {
105 Some(path) => util::path_to_config_string(&util::expand_and_absolutize_path(path)?)?,
106 None => util::path_to_config_string(&snapshot.active_path)?,
107 };
108
109 let windows = snapshot
110 .windows
111 .into_iter()
112 .map(ExportedWindow::from_snapshot)
113 .collect::<Result<Vec<_>>>()?;
114
115 Ok(Self {
116 path,
117 session_name: snapshot.session_name,
118 startup_window: snapshot.active_window,
119 startup_pane: snapshot.active_pane,
120 windows,
121 })
122 }
123
124 pub fn to_toml(&self) -> String {
125 let mut out = String::new();
126 out.push_str(&format!(
127 "#:schema {}\n",
128 config::schema_url("smux-project.schema.json")
129 ));
130 out.push_str(&format!("path = {}\n", toml_string(&self.path)));
131 out.push_str(&format!(
132 "session_name = {}\n",
133 toml_string(&self.session_name)
134 ));
135 out.push_str(&format!(
136 "startup_window = {}\n",
137 toml_string(&self.startup_window)
138 ));
139 out.push_str(&format!("startup_pane = {}\n", self.startup_pane));
140 out.push_str("windows = [\n");
141 for window in &self.windows {
142 out.push_str(" ");
143 out.push_str(&window.to_inline_toml(2));
144 out.push_str(",\n");
145 }
146 out.push_str("]\n");
147 out
148 }
149}
150
151impl ExportedWindow {
152 fn from_snapshot(window: WindowSnapshot) -> Result<Self> {
153 let all_same_cwd = all_panes_share_cwd(&window.panes);
154 let cwd = if all_same_cwd {
155 Some(util::path_to_config_string(&window.panes[0].cwd)?)
156 } else {
157 None
158 };
159
160 let panes = if window.panes.len() > 1 || !all_same_cwd {
161 Some(
162 window
163 .panes
164 .into_iter()
165 .enumerate()
166 .map(|(index, pane)| ExportedPane::from_snapshot(index, pane, cwd.as_deref()))
167 .collect::<Result<Vec<_>>>()?,
168 )
169 } else {
170 None
171 };
172
173 Ok(Self {
174 name: window.name,
175 cwd,
176 layout: None,
177 synchronize: window.synchronize,
178 panes,
179 })
180 }
181
182 fn to_inline_toml(&self, indent: usize) -> String {
183 let mut fields = vec![format!("name = {}", toml_string(&self.name))];
184 if let Some(cwd) = &self.cwd {
185 fields.push(format!("cwd = {}", toml_string(cwd)));
186 }
187 if let Some(layout) = &self.layout {
188 fields.push(format!("layout = {}", toml_string(layout)));
189 }
190 if self.synchronize {
191 fields.push("synchronize = true".to_owned());
192 }
193 if let Some(panes) = &self.panes {
194 let inner_indent = " ".repeat(indent + 2);
195 let outer_indent = " ".repeat(indent);
196 let rendered = panes
197 .iter()
198 .map(|pane| format!("{inner_indent}{},", pane.to_inline_toml()))
199 .collect::<Vec<_>>()
200 .join("\n");
201 fields.push(format!("panes = [\n{rendered}\n{outer_indent}]"));
202 }
203 format!("{{ {} }}", fields.join(", "))
204 }
205}
206
207impl ExportedPane {
208 fn from_snapshot(index: usize, pane: PaneSnapshot, window_cwd: Option<&str>) -> Result<Self> {
209 let cwd = util::path_to_config_string(&pane.cwd)?;
210 Ok(Self {
211 layout: if index == 0 {
212 None
213 } else {
214 pane.layout.map(render_pane_layout)
215 },
216 cwd: if window_cwd == Some(cwd.as_str()) {
217 None
218 } else {
219 Some(cwd)
220 },
221 })
222 }
223
224 fn to_inline_toml(&self) -> String {
225 let mut fields = Vec::new();
226 if let Some(layout) = &self.layout {
227 fields.push(format!("layout = {}", toml_string(layout)));
228 }
229 if let Some(cwd) = &self.cwd {
230 fields.push(format!("cwd = {}", toml_string(cwd)));
231 }
232 format!("{{ {} }}", fields.join(", "))
233 }
234}
235
236fn all_panes_share_cwd(panes: &[PaneSnapshot]) -> bool {
237 panes
238 .first()
239 .map(|first| panes.iter().all(|pane| pane.cwd == first.cwd))
240 .unwrap_or(false)
241}
242
243fn render_pane_layout(layout: crate::templates::PaneLayout) -> String {
244 let position = match layout.position {
245 crate::templates::PanePosition::Right => "right",
246 crate::templates::PanePosition::Left => "left",
247 crate::templates::PanePosition::Bottom => "bottom",
248 crate::templates::PanePosition::Top => "top",
249 };
250
251 match layout.size {
252 Some(size) => format!("{position} {size}"),
253 None => position.to_owned(),
254 }
255}
256
257fn toml_string(value: &str) -> String {
258 toml::Value::String(value.to_owned()).to_string()
259}
260
261#[cfg(test)]
262mod tests {
263 use std::path::PathBuf;
264 use std::sync::Mutex;
265
266 use super::{ExportedProject, ExportedWindow, capture_project};
267 use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
268 use crate::tmux::{PaneSnapshot, Tmux, WindowSnapshot};
269 use std::sync::Arc;
270
271 static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
272
273 #[test]
274 fn exported_project_renders_inline_toml() {
275 let project = ExportedProject {
276 path: "~/code/demo".to_owned(),
277 session_name: "demo".to_owned(),
278 startup_window: "editor".to_owned(),
279 startup_pane: 0,
280 windows: vec![ExportedWindow {
281 name: "editor".to_owned(),
282 cwd: Some("~/code/demo".to_owned()),
283 layout: None,
284 synchronize: false,
285 panes: Some(vec![
286 super::ExportedPane {
287 layout: None,
288 cwd: None,
289 },
290 super::ExportedPane {
291 layout: Some("right".to_owned()),
292 cwd: Some("~/code/demo/server".to_owned()),
293 },
294 ]),
295 }],
296 };
297
298 let toml = project.to_toml();
299 assert!(toml.starts_with("#:schema "));
300 assert!(toml.contains("path = \"~/code/demo\""));
301 assert!(toml.contains("session_name = \"demo\""));
302 assert!(toml.contains("windows = ["));
303 assert!(toml.contains("{ name = \"editor\""));
304 assert!(toml.contains("{ layout = \"right\", cwd = \"~/code/demo/server\" }"));
305 }
306
307 #[test]
308 fn capture_project_uses_active_pane_path_by_default() {
309 let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
310 unsafe {
311 std::env::set_var("HOME", "/Users/stefan");
312 }
313
314 let runner = Arc::new(FakeCommandRunner::new());
315 runner.push_capture(Ok(CommandOutput {
316 status: CommandStatus {
317 success: true,
318 code: Some(0),
319 },
320 stdout: Vec::new(),
321 stderr: Vec::new(),
322 }));
323 runner.push_capture(Ok(CommandOutput {
324 status: CommandStatus {
325 success: true,
326 code: Some(0),
327 },
328 stdout: b"@1\teditor\t1\n".to_vec(),
329 stderr: Vec::new(),
330 }));
331 runner.push_capture(Ok(CommandOutput {
332 status: CommandStatus {
333 success: true,
334 code: Some(0),
335 },
336 stdout: b"off\n".to_vec(),
337 stderr: Vec::new(),
338 }));
339 runner.push_capture(Ok(CommandOutput {
340 status: CommandStatus {
341 success: true,
342 code: Some(0),
343 },
344 stdout: b"0\t/Users/stefan/code/demo\t1\t0\t0\t100\t40\n1\t/Users/stefan/code/demo/server\t0\t50\t0\t50\t40\n".to_vec(),
345 stderr: Vec::new(),
346 }));
347
348 let tmux = Tmux::with_runner(runner);
349 let project = capture_project(&tmux, "demo", None).expect("capture should succeed");
350 assert_eq!(project.path, "~/code/demo");
351 assert_eq!(project.startup_window, "editor");
352 assert_eq!(project.startup_pane, 0);
353 assert_eq!(
354 project.windows[0]
355 .panes
356 .as_ref()
357 .expect("panes should exist")[1]
358 .layout
359 .as_deref(),
360 Some("right")
361 );
362
363 unsafe {
364 std::env::remove_var("HOME");
365 }
366 }
367
368 #[test]
369 fn exported_window_omits_duplicate_pane_cwds() {
370 let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
371 unsafe {
372 std::env::set_var("HOME", "/Users/stefan");
373 }
374
375 let window = ExportedWindow::from_snapshot(WindowSnapshot {
376 name: "editor".to_owned(),
377 synchronize: false,
378 active: true,
379 panes: vec![PaneSnapshot {
380 cwd: PathBuf::from("/Users/stefan/code/demo"),
381 active: true,
382 layout: None,
383 }],
384 })
385 .expect("window export should succeed");
386
387 assert_eq!(window.cwd.as_deref(), Some("~/code/demo"));
388 assert!(window.panes.is_none());
389
390 unsafe {
391 std::env::remove_var("HOME");
392 }
393 }
394}