Skip to main content

smux/
project_export.rs

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}