Skip to main content

smux/
session.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::config::{LoadedConfig, Template};
6use crate::templates;
7use crate::tmux::Tmux;
8use crate::util;
9
10pub const BUILTIN_TEMPLATE_NAME: &str = "__builtin__";
11
12pub fn connect_path(
13    tmux: &Tmux,
14    path: &Path,
15    loaded: Option<&LoadedConfig>,
16    override_template: Option<&str>,
17    override_name: Option<&str>,
18    project_detection: ProjectDetection,
19) -> Result<()> {
20    let normalized = util::normalize_path(path)?;
21    let resolved_project = match (loaded, project_detection) {
22        (_, ProjectDetection::Disabled) => None,
23        (Some(loaded), _) => crate::config::resolve_project(loaded, &normalized)?,
24        (None, _) => None,
25    };
26
27    let template = resolve_template(loaded, override_template, resolved_project.as_ref())?;
28
29    let session_name = match override_name {
30        Some(name) => util::validated_session_name(name)?,
31        None => match resolved_project
32            .as_ref()
33            .and_then(|project| project.project.session_name.as_deref())
34        {
35            Some(name) => util::validated_session_name(name)?,
36            None => util::session_name_from_path(&normalized)?,
37        },
38    };
39
40    if tmux.has_session(&session_name)? {
41        return tmux.switch_or_attach(&session_name);
42    }
43
44    let plan = templates::build_session_plan(&session_name, &normalized, &template)?;
45    tmux.create_session_from_plan(&plan)?;
46    tmux.switch_or_attach(&session_name)
47}
48
49pub fn connect_project(tmux: &Tmux, loaded: &LoadedConfig, project_name: &str) -> Result<()> {
50    let project = loaded
51        .projects
52        .get(project_name)
53        .with_context(|| format!("unknown project: {project_name}"))?;
54    connect_path(
55        tmux,
56        Path::new(&project.path),
57        Some(loaded),
58        None,
59        project.session_name.as_deref(),
60        ProjectDetection::Enabled,
61    )
62}
63
64#[derive(Debug, Clone, Copy, Eq, PartialEq)]
65pub enum ProjectDetection {
66    Enabled,
67    Disabled,
68}
69
70fn resolve_template(
71    loaded: Option<&LoadedConfig>,
72    override_template: Option<&str>,
73    project: Option<&crate::config::ResolvedProject<'_>>,
74) -> Result<Template> {
75    if let Some(template_name) = override_template {
76        if template_name == BUILTIN_TEMPLATE_NAME {
77            return Ok(templates::fallback_template());
78        }
79
80        let loaded = loaded.context("explicit --template requires a config file with templates")?;
81        return load_template(&loaded.config, template_name);
82    }
83
84    if let Some(project) = project {
85        let loaded = loaded.context("project template resolution requires config")?;
86        if let Some(template) =
87            crate::config::materialize_project_template(&loaded.config, project.project)?
88        {
89            return Ok(template);
90        }
91    }
92
93    if let Some(loaded) = loaded
94        && let Some(template_name) = &loaded.config.settings.default_template
95    {
96        return load_template(&loaded.config, template_name);
97    }
98
99    Ok(templates::fallback_template())
100}
101
102fn load_template(config: &crate::config::Config, template_name: &str) -> Result<Template> {
103    config
104        .templates
105        .get(template_name)
106        .cloned()
107        .ok_or_else(|| anyhow::anyhow!("unknown template: {template_name}"))
108}
109
110pub fn switch_existing(tmux: &Tmux, session: &str) -> Result<()> {
111    let session = util::validated_session_name(session)?;
112    tmux.ensure_session_exists(&session)?;
113    tmux.switch_or_attach(&session)
114}
115
116pub fn kill_existing(tmux: &Tmux, session: &str) -> Result<()> {
117    let session = util::validated_session_name(session)?;
118    tmux.ensure_session_exists(&session)?;
119    tmux.kill_session(&session)
120}
121
122#[cfg(test)]
123mod tests {
124    use anyhow::Result;
125
126    use crate::config::{
127        Config, LoadedConfig, Project, ResolvedProject, Settings, Template, Window,
128    };
129    use crate::templates;
130    use crate::util;
131    use std::collections::HashMap;
132    use std::path::PathBuf;
133
134    #[test]
135    fn sanitizes_session_names() {
136        assert_eq!(util::sanitize_session_name("my app"), "my_app");
137        assert_eq!(util::sanitize_session_name("api:v1"), "api_v1");
138        assert_eq!(util::sanitize_session_name("foo.bar"), "foo_bar");
139    }
140
141    #[test]
142    fn derives_session_name_from_path() -> Result<()> {
143        let tempdir = tempfile::tempdir()?;
144        let directory = tempdir.path().join("my-project");
145        std::fs::create_dir(&directory)?;
146
147        let session = util::session_name_from_path(&directory)?;
148        assert_eq!(session, "my-project");
149
150        Ok(())
151    }
152
153    #[test]
154    fn prefers_project_session_name_when_available() -> Result<()> {
155        let projects = HashMap::from([(
156            "demo".to_owned(),
157            Project {
158                path: "/tmp/demo".to_owned(),
159                template: None,
160                session_name: Some("demo-session".to_owned()),
161                root: None,
162                startup_window: None,
163                startup_pane: None,
164                windows: None,
165            },
166        )]);
167
168        let project = ResolvedProject {
169            name: "demo",
170            project: projects.get("demo").expect("project exists"),
171            normalized_path: PathBuf::from("/tmp/demo"),
172        };
173
174        let name = match project.project.session_name.as_deref() {
175            Some(name) => util::validated_session_name(name)?,
176            None => unreachable!(),
177        };
178
179        assert_eq!(name, "demo-session");
180        Ok(())
181    }
182
183    #[test]
184    fn explicit_template_must_exist() {
185        let config = Config {
186            settings: Settings::default(),
187            templates: HashMap::from([(
188                "default".to_owned(),
189                Template {
190                    root: None,
191                    startup_window: None,
192                    startup_pane: None,
193                    windows: vec![Window {
194                        name: "main".to_owned(),
195                        cwd: None,
196                        pre_command: None,
197                        command: None,
198                        layout: None,
199                        synchronize: false,
200                        panes: None,
201                    }],
202                },
203            )]),
204        };
205
206        let loaded = LoadedConfig {
207            path: PathBuf::from("/tmp/config.toml"),
208            config_exists: true,
209            project_dir: PathBuf::from("/tmp/projects"),
210            config,
211            projects: HashMap::new(),
212        };
213
214        let error = super::resolve_template(Some(&loaded), Some("missing"), None)
215            .expect_err("missing template should fail");
216        assert!(error.to_string().contains("unknown template"));
217    }
218
219    #[test]
220    fn falls_back_to_builtin_template_without_config() -> Result<()> {
221        let template = super::resolve_template(None, None, None)?;
222        assert_eq!(template.windows.len(), 1);
223        assert_eq!(
224            template.windows[0].name,
225            templates::fallback_template().windows[0].name
226        );
227        Ok(())
228    }
229
230    #[test]
231    fn project_detection_can_be_disabled() {
232        let disabled = super::ProjectDetection::Disabled;
233        assert_eq!(disabled, super::ProjectDetection::Disabled);
234    }
235
236    #[test]
237    fn explicit_template_overrides_project_and_default() -> Result<()> {
238        let loaded = LoadedConfig {
239            path: PathBuf::from("/tmp/config.toml"),
240            config_exists: true,
241            project_dir: PathBuf::from("/tmp/projects"),
242            config: Config {
243                settings: Settings {
244                    default_template: Some("default".to_owned()),
245                    ..Default::default()
246                },
247                templates: HashMap::from([
248                    (
249                        "default".to_owned(),
250                        Template {
251                            root: None,
252                            startup_window: None,
253                            startup_pane: None,
254                            windows: vec![Window {
255                                name: "default-window".to_owned(),
256                                cwd: None,
257                                pre_command: None,
258                                command: None,
259                                layout: None,
260                                synchronize: false,
261                                panes: None,
262                            }],
263                        },
264                    ),
265                    (
266                        "project".to_owned(),
267                        Template {
268                            root: None,
269                            startup_window: None,
270                            startup_pane: None,
271                            windows: vec![Window {
272                                name: "project-window".to_owned(),
273                                cwd: None,
274                                pre_command: None,
275                                command: None,
276                                layout: None,
277                                synchronize: false,
278                                panes: None,
279                            }],
280                        },
281                    ),
282                    (
283                        "explicit".to_owned(),
284                        Template {
285                            root: None,
286                            startup_window: None,
287                            startup_pane: None,
288                            windows: vec![Window {
289                                name: "explicit-window".to_owned(),
290                                cwd: None,
291                                pre_command: None,
292                                command: None,
293                                layout: None,
294                                synchronize: false,
295                                panes: None,
296                            }],
297                        },
298                    ),
299                ]),
300            },
301            projects: HashMap::from([(
302                "demo".to_owned(),
303                Project {
304                    path: "/tmp/demo".to_owned(),
305                    template: Some("project".to_owned()),
306                    session_name: None,
307                    root: None,
308                    startup_window: None,
309                    startup_pane: None,
310                    windows: None,
311                },
312            )]),
313        };
314
315        let project = ResolvedProject {
316            name: "demo",
317            project: loaded.projects.get("demo").expect("project exists"),
318            normalized_path: PathBuf::from("/tmp/demo"),
319        };
320
321        let template = super::resolve_template(Some(&loaded), Some("explicit"), Some(&project))?;
322        assert_eq!(template.windows[0].name, "explicit-window");
323        Ok(())
324    }
325
326    #[test]
327    fn default_template_applies_without_project_or_override() -> Result<()> {
328        let config = Config {
329            settings: Settings {
330                default_template: Some("default".to_owned()),
331                ..Default::default()
332            },
333            templates: HashMap::from([(
334                "default".to_owned(),
335                Template {
336                    root: None,
337                    startup_window: None,
338                    startup_pane: None,
339                    windows: vec![Window {
340                        name: "default-window".to_owned(),
341                        cwd: None,
342                        pre_command: None,
343                        command: None,
344                        layout: None,
345                        synchronize: false,
346                        panes: None,
347                    }],
348                },
349            )]),
350        };
351
352        let loaded = LoadedConfig {
353            path: PathBuf::from("/tmp/config.toml"),
354            config_exists: true,
355            project_dir: PathBuf::from("/tmp/projects"),
356            config,
357            projects: HashMap::new(),
358        };
359
360        let template = super::resolve_template(Some(&loaded), None, None)?;
361        assert_eq!(template.windows[0].name, "default-window");
362        Ok(())
363    }
364}