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}