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