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            invalid_projects: Vec::new(),
213        };
214
215        let error = super::resolve_template(Some(&loaded), Some("missing"), None)
216            .expect_err("missing template should fail");
217        assert!(error.to_string().contains("unknown template"));
218    }
219
220    #[test]
221    fn falls_back_to_builtin_template_without_config() -> Result<()> {
222        let template = super::resolve_template(None, None, None)?;
223        assert_eq!(template.windows.len(), 1);
224        assert_eq!(
225            template.windows[0].name,
226            templates::fallback_template().windows[0].name
227        );
228        Ok(())
229    }
230
231    #[test]
232    fn project_detection_can_be_disabled() {
233        let disabled = super::ProjectDetection::Disabled;
234        assert_eq!(disabled, super::ProjectDetection::Disabled);
235    }
236
237    #[test]
238    fn explicit_template_overrides_project_and_default() -> Result<()> {
239        let loaded = LoadedConfig {
240            path: PathBuf::from("/tmp/config.toml"),
241            config_exists: true,
242            project_dir: PathBuf::from("/tmp/projects"),
243            config: Config {
244                settings: Settings {
245                    default_template: Some("default".to_owned()),
246                    ..Default::default()
247                },
248                templates: HashMap::from([
249                    (
250                        "default".to_owned(),
251                        Template {
252                            root: None,
253                            startup_window: None,
254                            startup_pane: None,
255                            windows: vec![Window {
256                                name: "default-window".to_owned(),
257                                cwd: None,
258                                pre_command: None,
259                                command: None,
260                                layout: None,
261                                synchronize: false,
262                                panes: None,
263                            }],
264                        },
265                    ),
266                    (
267                        "project".to_owned(),
268                        Template {
269                            root: None,
270                            startup_window: None,
271                            startup_pane: None,
272                            windows: vec![Window {
273                                name: "project-window".to_owned(),
274                                cwd: None,
275                                pre_command: None,
276                                command: None,
277                                layout: None,
278                                synchronize: false,
279                                panes: None,
280                            }],
281                        },
282                    ),
283                    (
284                        "explicit".to_owned(),
285                        Template {
286                            root: None,
287                            startup_window: None,
288                            startup_pane: None,
289                            windows: vec![Window {
290                                name: "explicit-window".to_owned(),
291                                cwd: None,
292                                pre_command: None,
293                                command: None,
294                                layout: None,
295                                synchronize: false,
296                                panes: None,
297                            }],
298                        },
299                    ),
300                ]),
301            },
302            projects: HashMap::from([(
303                "demo".to_owned(),
304                Project {
305                    path: "/tmp/demo".to_owned(),
306                    template: Some("project".to_owned()),
307                    session_name: None,
308                    root: None,
309                    startup_window: None,
310                    startup_pane: None,
311                    windows: None,
312                },
313            )]),
314            invalid_projects: Vec::new(),
315        };
316
317        let project = ResolvedProject {
318            name: "demo",
319            project: loaded.projects.get("demo").expect("project exists"),
320            normalized_path: PathBuf::from("/tmp/demo"),
321        };
322
323        let template = super::resolve_template(Some(&loaded), Some("explicit"), Some(&project))?;
324        assert_eq!(template.windows[0].name, "explicit-window");
325        Ok(())
326    }
327
328    #[test]
329    fn default_template_applies_without_project_or_override() -> Result<()> {
330        let config = Config {
331            settings: Settings {
332                default_template: Some("default".to_owned()),
333                ..Default::default()
334            },
335            templates: HashMap::from([(
336                "default".to_owned(),
337                Template {
338                    root: None,
339                    startup_window: None,
340                    startup_pane: None,
341                    windows: vec![Window {
342                        name: "default-window".to_owned(),
343                        cwd: None,
344                        pre_command: None,
345                        command: None,
346                        layout: None,
347                        synchronize: false,
348                        panes: None,
349                    }],
350                },
351            )]),
352        };
353
354        let loaded = LoadedConfig {
355            path: PathBuf::from("/tmp/config.toml"),
356            config_exists: true,
357            project_dir: PathBuf::from("/tmp/projects"),
358            config,
359            projects: HashMap::new(),
360            invalid_projects: Vec::new(),
361        };
362
363        let template = super::resolve_template(Some(&loaded), None, None)?;
364        assert_eq!(template.windows[0].name, "default-window");
365        Ok(())
366    }
367}