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}