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!("path = {}\n", toml_string(&self.path)));
127 out.push_str(&format!(
128 "session_name = {}\n",
129 toml_string(&self.session_name)
130 ));
131 out.push_str(&format!(
132 "startup_window = {}\n",
133 toml_string(&self.startup_window)
134 ));
135 out.push_str(&format!("startup_pane = {}\n", self.startup_pane));
136 out.push_str("windows = [\n");
137 for window in &self.windows {
138 out.push_str(" ");
139 out.push_str(&window.to_inline_toml(2));
140 out.push_str(",\n");
141 }
142 out.push_str("]\n");
143 out
144 }
145}
146
147impl ExportedWindow {
148 fn from_snapshot(window: WindowSnapshot) -> Result<Self> {
149 let all_same_cwd = all_panes_share_cwd(&window.panes);
150 let cwd = if all_same_cwd {
151 Some(util::path_to_config_string(&window.panes[0].cwd)?)
152 } else {
153 None
154 };
155
156 let panes = if window.panes.len() > 1 || !all_same_cwd {
157 Some(
158 window
159 .panes
160 .into_iter()
161 .enumerate()
162 .map(|(index, pane)| ExportedPane::from_snapshot(index, pane, cwd.as_deref()))
163 .collect::<Result<Vec<_>>>()?,
164 )
165 } else {
166 None
167 };
168
169 Ok(Self {
170 name: window.name,
171 cwd,
172 layout: None,
173 synchronize: window.synchronize,
174 panes,
175 })
176 }
177
178 fn to_inline_toml(&self, indent: usize) -> String {
179 let mut fields = vec![format!("name = {}", toml_string(&self.name))];
180 if let Some(cwd) = &self.cwd {
181 fields.push(format!("cwd = {}", toml_string(cwd)));
182 }
183 if let Some(layout) = &self.layout {
184 fields.push(format!("layout = {}", toml_string(layout)));
185 }
186 if self.synchronize {
187 fields.push("synchronize = true".to_owned());
188 }
189 if let Some(panes) = &self.panes {
190 let inner_indent = " ".repeat(indent + 2);
191 let outer_indent = " ".repeat(indent);
192 let rendered = panes
193 .iter()
194 .map(|pane| format!("{inner_indent}{},", pane.to_inline_toml()))
195 .collect::<Vec<_>>()
196 .join("\n");
197 fields.push(format!("panes = [\n{rendered}\n{outer_indent}]"));
198 }
199 format!("{{ {} }}", fields.join(", "))
200 }
201}
202
203impl ExportedPane {
204 fn from_snapshot(index: usize, pane: PaneSnapshot, window_cwd: Option<&str>) -> Result<Self> {
205 let cwd = util::path_to_config_string(&pane.cwd)?;
206 Ok(Self {
207 layout: if index == 0 {
208 None
209 } else {
210 pane.layout.map(render_pane_layout)
211 },
212 cwd: if window_cwd == Some(cwd.as_str()) {
213 None
214 } else {
215 Some(cwd)
216 },
217 })
218 }
219
220 fn to_inline_toml(&self) -> String {
221 let mut fields = Vec::new();
222 if let Some(layout) = &self.layout {
223 fields.push(format!("layout = {}", toml_string(layout)));
224 }
225 if let Some(cwd) = &self.cwd {
226 fields.push(format!("cwd = {}", toml_string(cwd)));
227 }
228 format!("{{ {} }}", fields.join(", "))
229 }
230}
231
232fn all_panes_share_cwd(panes: &[PaneSnapshot]) -> bool {
233 panes
234 .first()
235 .map(|first| panes.iter().all(|pane| pane.cwd == first.cwd))
236 .unwrap_or(false)
237}
238
239fn render_pane_layout(layout: crate::templates::PaneLayout) -> String {
240 let position = match layout.position {
241 crate::templates::PanePosition::Right => "right",
242 crate::templates::PanePosition::Left => "left",
243 crate::templates::PanePosition::Bottom => "bottom",
244 crate::templates::PanePosition::Top => "top",
245 };
246
247 match layout.size {
248 Some(size) => format!("{position} {size}"),
249 None => position.to_owned(),
250 }
251}
252
253fn toml_string(value: &str) -> String {
254 toml::Value::String(value.to_owned()).to_string()
255}
256
257#[cfg(test)]
258mod tests {
259 use std::path::PathBuf;
260 use std::sync::Mutex;
261
262 use super::{ExportedProject, ExportedWindow, capture_project};
263 use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};
264 use crate::tmux::{PaneSnapshot, Tmux, WindowSnapshot};
265 use std::sync::Arc;
266
267 static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
268
269 #[test]
270 fn exported_project_renders_inline_toml() {
271 let project = ExportedProject {
272 path: "~/code/demo".to_owned(),
273 session_name: "demo".to_owned(),
274 startup_window: "editor".to_owned(),
275 startup_pane: 0,
276 windows: vec![ExportedWindow {
277 name: "editor".to_owned(),
278 cwd: Some("~/code/demo".to_owned()),
279 layout: None,
280 synchronize: false,
281 panes: Some(vec![
282 super::ExportedPane {
283 layout: None,
284 cwd: None,
285 },
286 super::ExportedPane {
287 layout: Some("right".to_owned()),
288 cwd: Some("~/code/demo/server".to_owned()),
289 },
290 ]),
291 }],
292 };
293
294 let toml = project.to_toml();
295 assert!(toml.contains("path = \"~/code/demo\""));
296 assert!(toml.contains("session_name = \"demo\""));
297 assert!(toml.contains("windows = ["));
298 assert!(toml.contains("{ name = \"editor\""));
299 assert!(toml.contains("{ layout = \"right\", cwd = \"~/code/demo/server\" }"));
300 }
301
302 #[test]
303 fn capture_project_uses_active_pane_path_by_default() {
304 let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
305 unsafe {
306 std::env::set_var("HOME", "/Users/stefan");
307 }
308
309 let runner = Arc::new(FakeCommandRunner::new());
310 runner.push_capture(Ok(CommandOutput {
311 status: CommandStatus {
312 success: true,
313 code: Some(0),
314 },
315 stdout: Vec::new(),
316 stderr: Vec::new(),
317 }));
318 runner.push_capture(Ok(CommandOutput {
319 status: CommandStatus {
320 success: true,
321 code: Some(0),
322 },
323 stdout: b"@1\teditor\t1\n".to_vec(),
324 stderr: Vec::new(),
325 }));
326 runner.push_capture(Ok(CommandOutput {
327 status: CommandStatus {
328 success: true,
329 code: Some(0),
330 },
331 stdout: b"off\n".to_vec(),
332 stderr: Vec::new(),
333 }));
334 runner.push_capture(Ok(CommandOutput {
335 status: CommandStatus {
336 success: true,
337 code: Some(0),
338 },
339 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(),
340 stderr: Vec::new(),
341 }));
342
343 let tmux = Tmux::with_runner(runner);
344 let project = capture_project(&tmux, "demo", None).expect("capture should succeed");
345 assert_eq!(project.path, "~/code/demo");
346 assert_eq!(project.startup_window, "editor");
347 assert_eq!(project.startup_pane, 0);
348 assert_eq!(
349 project.windows[0]
350 .panes
351 .as_ref()
352 .expect("panes should exist")[1]
353 .layout
354 .as_deref(),
355 Some("right")
356 );
357
358 unsafe {
359 std::env::remove_var("HOME");
360 }
361 }
362
363 #[test]
364 fn exported_window_omits_duplicate_pane_cwds() {
365 let _guard = HOME_ENV_LOCK.lock().expect("home env lock should work");
366 unsafe {
367 std::env::set_var("HOME", "/Users/stefan");
368 }
369
370 let window = ExportedWindow::from_snapshot(WindowSnapshot {
371 name: "editor".to_owned(),
372 synchronize: false,
373 active: true,
374 panes: vec![PaneSnapshot {
375 cwd: PathBuf::from("/Users/stefan/code/demo"),
376 active: true,
377 layout: None,
378 }],
379 })
380 .expect("window export should succeed");
381
382 assert_eq!(window.cwd.as_deref(), Some("~/code/demo"));
383 assert!(window.panes.is_none());
384
385 unsafe {
386 std::env::remove_var("HOME");
387 }
388 }
389}