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
116#[cfg(test)]
117mod tests {
118    use anyhow::Result;
119
120    use crate::config::{
121        Config, LoadedConfig, Project, ResolvedProject, Settings, Template, Window,
122    };
123    use crate::templates;
124    use crate::util;
125    use std::collections::HashMap;
126    use std::path::PathBuf;
127
128    #[test]
129    fn sanitizes_session_names() {
130        assert_eq!(util::sanitize_session_name("my app"), "my_app");
131        assert_eq!(util::sanitize_session_name("api:v1"), "api_v1");
132        assert_eq!(util::sanitize_session_name("foo.bar"), "foo_bar");
133    }
134
135    #[test]
136    fn derives_session_name_from_path() -> Result<()> {
137        let tempdir = tempfile::tempdir()?;
138        let directory = tempdir.path().join("my-project");
139        std::fs::create_dir(&directory)?;
140
141        let session = util::session_name_from_path(&directory)?;
142        assert_eq!(session, "my-project");
143
144        Ok(())
145    }
146
147    #[test]
148    fn prefers_project_session_name_when_available() -> Result<()> {
149        let projects = HashMap::from([(
150            "demo".to_owned(),
151            Project {
152                path: "/tmp/demo".to_owned(),
153                template: None,
154                session_name: Some("demo-session".to_owned()),
155                root: None,
156                startup_window: None,
157                startup_pane: None,
158                windows: None,
159            },
160        )]);
161
162        let project = ResolvedProject {
163            name: "demo",
164            project: projects.get("demo").expect("project exists"),
165            normalized_path: PathBuf::from("/tmp/demo"),
166        };
167
168        let name = match project.project.session_name.as_deref() {
169            Some(name) => util::validated_session_name(name)?,
170            None => unreachable!(),
171        };
172
173        assert_eq!(name, "demo-session");
174        Ok(())
175    }
176
177    #[test]
178    fn explicit_template_must_exist() {
179        let config = Config {
180            settings: Settings::default(),
181            templates: HashMap::from([(
182                "default".to_owned(),
183                Template {
184                    root: None,
185                    startup_window: None,
186                    startup_pane: None,
187                    windows: vec![Window {
188                        name: "main".to_owned(),
189                        cwd: None,
190                        pre_command: None,
191                        command: None,
192                        layout: None,
193                        synchronize: false,
194                        panes: None,
195                    }],
196                },
197            )]),
198        };
199
200        let loaded = LoadedConfig {
201            path: PathBuf::from("/tmp/config.toml"),
202            config_exists: true,
203            project_dir: PathBuf::from("/tmp/projects"),
204            config,
205            projects: HashMap::new(),
206        };
207
208        let error = super::resolve_template(Some(&loaded), Some("missing"), None)
209            .expect_err("missing template should fail");
210        assert!(error.to_string().contains("unknown template"));
211    }
212
213    #[test]
214    fn falls_back_to_builtin_template_without_config() -> Result<()> {
215        let template = super::resolve_template(None, None, None)?;
216        assert_eq!(template.windows.len(), 1);
217        assert_eq!(
218            template.windows[0].name,
219            templates::fallback_template().windows[0].name
220        );
221        Ok(())
222    }
223
224    #[test]
225    fn project_detection_can_be_disabled() {
226        let disabled = super::ProjectDetection::Disabled;
227        assert_eq!(disabled, super::ProjectDetection::Disabled);
228    }
229
230    #[test]
231    fn explicit_template_overrides_project_and_default() -> Result<()> {
232        let loaded = LoadedConfig {
233            path: PathBuf::from("/tmp/config.toml"),
234            config_exists: true,
235            project_dir: PathBuf::from("/tmp/projects"),
236            config: Config {
237                settings: Settings {
238                    default_template: Some("default".to_owned()),
239                    ..Default::default()
240                },
241                templates: HashMap::from([
242                    (
243                        "default".to_owned(),
244                        Template {
245                            root: None,
246                            startup_window: None,
247                            startup_pane: None,
248                            windows: vec![Window {
249                                name: "default-window".to_owned(),
250                                cwd: None,
251                                pre_command: None,
252                                command: None,
253                                layout: None,
254                                synchronize: false,
255                                panes: None,
256                            }],
257                        },
258                    ),
259                    (
260                        "project".to_owned(),
261                        Template {
262                            root: None,
263                            startup_window: None,
264                            startup_pane: None,
265                            windows: vec![Window {
266                                name: "project-window".to_owned(),
267                                cwd: None,
268                                pre_command: None,
269                                command: None,
270                                layout: None,
271                                synchronize: false,
272                                panes: None,
273                            }],
274                        },
275                    ),
276                    (
277                        "explicit".to_owned(),
278                        Template {
279                            root: None,
280                            startup_window: None,
281                            startup_pane: None,
282                            windows: vec![Window {
283                                name: "explicit-window".to_owned(),
284                                cwd: None,
285                                pre_command: None,
286                                command: None,
287                                layout: None,
288                                synchronize: false,
289                                panes: None,
290                            }],
291                        },
292                    ),
293                ]),
294            },
295            projects: HashMap::from([(
296                "demo".to_owned(),
297                Project {
298                    path: "/tmp/demo".to_owned(),
299                    template: Some("project".to_owned()),
300                    session_name: None,
301                    root: None,
302                    startup_window: None,
303                    startup_pane: None,
304                    windows: None,
305                },
306            )]),
307        };
308
309        let project = ResolvedProject {
310            name: "demo",
311            project: loaded.projects.get("demo").expect("project exists"),
312            normalized_path: PathBuf::from("/tmp/demo"),
313        };
314
315        let template = super::resolve_template(Some(&loaded), Some("explicit"), Some(&project))?;
316        assert_eq!(template.windows[0].name, "explicit-window");
317        Ok(())
318    }
319
320    #[test]
321    fn default_template_applies_without_project_or_override() -> Result<()> {
322        let config = Config {
323            settings: Settings {
324                default_template: Some("default".to_owned()),
325                ..Default::default()
326            },
327            templates: HashMap::from([(
328                "default".to_owned(),
329                Template {
330                    root: None,
331                    startup_window: None,
332                    startup_pane: None,
333                    windows: vec![Window {
334                        name: "default-window".to_owned(),
335                        cwd: None,
336                        pre_command: None,
337                        command: None,
338                        layout: None,
339                        synchronize: false,
340                        panes: None,
341                    }],
342                },
343            )]),
344        };
345
346        let loaded = LoadedConfig {
347            path: PathBuf::from("/tmp/config.toml"),
348            config_exists: true,
349            project_dir: PathBuf::from("/tmp/projects"),
350            config,
351            projects: HashMap::new(),
352        };
353
354        let template = super::resolve_template(Some(&loaded), None, None)?;
355        assert_eq!(template.windows[0].name, "default-window");
356        Ok(())
357    }
358}