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!("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}