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}