1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7
8use crate::util;
9
10pub const STARTER_CONFIG: &str = r#"[settings]
11default_template = "default"
12icons = "auto"
13
14[settings.icon_colors]
15session = 75
16directory = 108
17template = 179
18project = 81
19
20[templates.default]
21startup_window = "main"
22windows = [{ name = "main" }]
23
24[templates.rust]
25startup_window = "editor"
26startup_pane = 0
27windows = [
28 { name = "editor", pre_command = "source .venv/bin/activate", command = "nvim" },
29 { name = "run", synchronize = true, layout = "main-horizontal", panes = [
30 { command = "source .venv/bin/activate" },
31 { command = "cargo run" },
32 { layout = "right 40%", command = "cargo test" },
33 ] },
34]
35"#;
36
37pub const STARTER_PROJECT: &str = r#"path = "~/code/example"
38session_name = "example"
39template = "rust"
40"#;
41
42#[derive(Debug, Clone, Deserialize, Default)]
43pub struct Config {
44 #[serde(default)]
45 pub settings: Settings,
46 #[serde(default)]
47 pub templates: HashMap<String, Template>,
48}
49
50#[derive(Debug, Clone, Deserialize, Default)]
51pub struct Settings {
52 pub default_template: Option<String>,
53 #[serde(default)]
54 pub icons: IconMode,
55 #[serde(default)]
56 pub icon_colors: IconColors,
57}
58
59#[derive(Debug, Clone, Copy, Deserialize, Default, Eq, PartialEq)]
60#[serde(rename_all = "lowercase")]
61pub enum IconMode {
62 #[default]
63 Auto,
64 Always,
65 Never,
66}
67
68impl IconMode {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Self::Auto => "auto",
72 Self::Always => "always",
73 Self::Never => "never",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
79pub struct IconColors {
80 pub session: u8,
81 pub directory: u8,
82 pub template: u8,
83 pub project: u8,
84}
85
86impl Default for IconColors {
87 fn default() -> Self {
88 Self {
89 session: 75,
90 directory: 108,
91 template: 179,
92 project: 81,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Deserialize, Default)]
98pub struct Project {
99 pub path: String,
100 pub session_name: Option<String>,
101 pub template: Option<String>,
102 pub root: Option<String>,
103 pub startup_window: Option<String>,
104 pub startup_pane: Option<usize>,
105 pub windows: Option<Vec<Window>>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
109pub struct Template {
110 pub root: Option<String>,
111 pub startup_window: Option<String>,
112 pub startup_pane: Option<usize>,
113 pub windows: Vec<Window>,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct Window {
118 pub name: String,
119 pub cwd: Option<String>,
120 pub pre_command: Option<String>,
121 pub command: Option<String>,
122 pub layout: Option<String>,
123 #[serde(default)]
124 pub synchronize: bool,
125 pub panes: Option<Vec<Pane>>,
126}
127
128#[derive(Debug, Clone, Deserialize)]
129pub struct Pane {
130 pub layout: Option<String>,
131 pub command: Option<String>,
132 pub cwd: Option<String>,
133}
134
135#[derive(Debug, Clone)]
136pub struct LoadedConfig {
137 pub path: PathBuf,
138 pub config_exists: bool,
139 pub project_dir: PathBuf,
140 pub config: Config,
141 pub projects: HashMap<String, Project>,
142}
143
144#[derive(Debug, Clone)]
145pub struct ResolvedProject<'a> {
146 pub name: &'a str,
147 pub project: &'a Project,
148 pub normalized_path: PathBuf,
149}
150
151pub fn default_config_dir() -> Result<PathBuf> {
152 if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
153 Ok(PathBuf::from(config_home).join("smux"))
154 } else {
155 let home = std::env::var_os("HOME").context("could not resolve HOME for config path")?;
156 Ok(PathBuf::from(home).join(".config").join("smux"))
157 }
158}
159
160pub fn default_config_path() -> Result<PathBuf> {
161 Ok(default_config_dir()?.join("config.toml"))
162}
163
164pub fn default_projects_dir() -> Result<PathBuf> {
165 Ok(default_config_dir()?.join("projects"))
166}
167
168pub fn projects_dir_for_config_path(path: &Path) -> PathBuf {
169 path.parent()
170 .map(|parent| parent.join("projects"))
171 .unwrap_or_else(|| PathBuf::from("projects"))
172}
173
174pub fn load(path: Option<&Path>) -> Result<LoadedConfig> {
175 let path = match path {
176 Some(path) => path.to_path_buf(),
177 None => default_config_path()?,
178 };
179
180 if !path.exists() {
181 bail!("failed to read config {}", path.display());
182 }
183
184 load_workspace(Some(&path))
185}
186
187pub fn load_workspace(path: Option<&Path>) -> Result<LoadedConfig> {
188 let path = match path {
189 Some(path) => path.to_path_buf(),
190 None => default_config_path()?,
191 };
192 let project_dir = projects_dir_for_config_path(&path);
193 let config_exists = path.exists();
194
195 let config = if config_exists {
196 let text = fs::read_to_string(&path)
197 .with_context(|| format!("failed to read config {}", path.display()))?;
198 let config: Config = toml::from_str(&text)
199 .with_context(|| format!("failed to parse config {}", path.display()))?;
200 validate_config(&config)?;
201 config
202 } else {
203 Config::default()
204 };
205
206 let projects = load_projects(&project_dir, &config)?;
207
208 Ok(LoadedConfig {
209 path,
210 config_exists,
211 project_dir,
212 config,
213 projects,
214 })
215}
216
217pub fn load_optional(path: Option<&Path>) -> Result<Option<LoadedConfig>> {
218 let path = match path {
219 Some(path) => path.to_path_buf(),
220 None => default_config_path()?,
221 };
222 let project_dir = projects_dir_for_config_path(&path);
223
224 if !path.exists() && !project_dir.exists() {
225 return Ok(None);
226 }
227
228 load_workspace(Some(&path)).map(Some)
229}
230
231pub fn init(path: Option<&Path>) -> Result<PathBuf> {
232 let path = match path {
233 Some(path) => path.to_path_buf(),
234 None => default_config_path()?,
235 };
236
237 if path.exists() {
238 bail!("config already exists at {}", path.display());
239 }
240
241 let config_dir = path
242 .parent()
243 .context("config path did not have a parent directory")?;
244 let project_dir = config_dir.join("projects");
245
246 fs::create_dir_all(config_dir)
247 .with_context(|| format!("failed to create config directory {}", config_dir.display()))?;
248 fs::create_dir_all(&project_dir).with_context(|| {
249 format!(
250 "failed to create project directory {}",
251 project_dir.display()
252 )
253 })?;
254
255 fs::write(&path, STARTER_CONFIG)
256 .with_context(|| format!("failed to write starter config to {}", path.display()))?;
257
258 let starter_project_path = project_dir.join("example.toml");
259 fs::write(&starter_project_path, STARTER_PROJECT).with_context(|| {
260 format!(
261 "failed to write starter project to {}",
262 starter_project_path.display()
263 )
264 })?;
265
266 Ok(path)
267}
268
269pub fn validate_config(config: &Config) -> Result<()> {
270 for (template_name, template) in &config.templates {
271 validate_template(template_name, template)?;
272 }
273
274 if let Some(default_template) = &config.settings.default_template
275 && !config.templates.contains_key(default_template)
276 {
277 bail!("default_template \"{default_template}\" was not found");
278 }
279
280 Ok(())
281}
282
283fn validate_template(name: &str, template: &Template) -> Result<()> {
284 if template.windows.is_empty() {
285 bail!("{name} must contain at least one window");
286 }
287
288 if let Some(startup_window) = &template.startup_window
289 && !template
290 .windows
291 .iter()
292 .any(|window| window.name == *startup_window)
293 {
294 bail!("{name} references missing startup window \"{startup_window}\"");
295 }
296
297 for window in &template.windows {
298 validate_window(name, window)?;
299 }
300
301 Ok(())
302}
303
304fn validate_window(owner_name: &str, window: &Window) -> Result<()> {
305 if window.command.is_some() && window.panes.is_some() {
306 bail!(
307 "{owner_name} window \"{}\" cannot define both command and panes",
308 window.name
309 );
310 }
311
312 if let Some(panes) = &window.panes
313 && panes.is_empty()
314 {
315 bail!(
316 "{owner_name} window \"{}\" cannot define an empty panes array",
317 window.name
318 );
319 }
320
321 Ok(())
322}
323
324fn load_projects(project_dir: &Path, config: &Config) -> Result<HashMap<String, Project>> {
325 if !project_dir.exists() {
326 return Ok(HashMap::new());
327 }
328
329 let mut files = fs::read_dir(project_dir)
330 .with_context(|| format!("failed to read project directory {}", project_dir.display()))?
331 .collect::<std::io::Result<Vec<_>>>()
332 .with_context(|| format!("failed to read project directory {}", project_dir.display()))?;
333 files.sort_by_key(|entry| entry.file_name());
334
335 let mut projects = HashMap::new();
336
337 for entry in files {
338 let path = entry.path();
339 if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
340 continue;
341 }
342
343 let name = path
344 .file_stem()
345 .and_then(|stem| stem.to_str())
346 .context("project file name was not valid utf-8")?
347 .to_owned();
348
349 let text = fs::read_to_string(&path)
350 .with_context(|| format!("failed to read project {}", path.display()))?;
351 let project: Project = toml::from_str(&text)
352 .with_context(|| format!("failed to parse project {}", path.display()))?;
353 validate_project(&name, &project, config)?;
354 projects.insert(name, project);
355 }
356
357 Ok(projects)
358}
359
360fn validate_project(name: &str, project: &Project, config: &Config) -> Result<()> {
361 util::expand_and_absolutize_path(Path::new(&project.path))
362 .with_context(|| format!("project \"{name}\" has an invalid path {}", project.path))?;
363
364 if let Some(template_name) = &project.template
365 && !config.templates.contains_key(template_name)
366 {
367 bail!("template \"{template_name}\" referenced by project \"{name}\" was not found");
368 }
369
370 let has_direct_session_definition = project.root.is_some()
371 || project.startup_window.is_some()
372 || project.startup_pane.is_some()
373 || project.windows.is_some();
374
375 if has_direct_session_definition {
376 let effective = materialize_project_template(config, project)?
377 .context("project materialization unexpectedly returned no template")?;
378 validate_template(&format!("project \"{name}\""), &effective)?;
379 }
380
381 Ok(())
382}
383
384pub fn materialize_project_template(
385 config: &Config,
386 project: &Project,
387) -> Result<Option<Template>> {
388 let base = match &project.template {
389 Some(template_name) => Some(
390 config
391 .templates
392 .get(template_name)
393 .cloned()
394 .ok_or_else(|| anyhow::anyhow!("unknown template: {template_name}"))?,
395 ),
396 None => None,
397 };
398
399 let has_direct_session_definition = project.root.is_some()
400 || project.startup_window.is_some()
401 || project.startup_pane.is_some()
402 || project.windows.is_some();
403
404 if !has_direct_session_definition {
405 return Ok(base);
406 }
407
408 let mut effective = base.unwrap_or(Template {
409 root: None,
410 startup_window: None,
411 startup_pane: None,
412 windows: Vec::new(),
413 });
414
415 if let Some(root) = &project.root {
416 effective.root = Some(root.clone());
417 }
418 if let Some(startup_window) = &project.startup_window {
419 effective.startup_window = Some(startup_window.clone());
420 }
421 if let Some(startup_pane) = project.startup_pane {
422 effective.startup_pane = Some(startup_pane);
423 }
424 if let Some(windows) = &project.windows {
425 effective.windows = windows.clone();
426 }
427
428 Ok(Some(effective))
429}
430
431pub fn resolve_project<'a>(
432 loaded: &'a LoadedConfig,
433 path: &Path,
434) -> Result<Option<ResolvedProject<'a>>> {
435 let normalized = util::expand_and_normalize_path(path)?;
436
437 for (name, project) in &loaded.projects {
438 let project_path = util::expand_and_absolutize_path(Path::new(&project.path))?;
439 if project_path == normalized {
440 return Ok(Some(ResolvedProject {
441 name,
442 project,
443 normalized_path: project_path,
444 }));
445 }
446 }
447
448 Ok(None)
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{
454 Config, IconColors, IconMode, STARTER_CONFIG, STARTER_PROJECT, default_projects_dir, load,
455 load_optional, load_workspace, materialize_project_template, resolve_project,
456 validate_config,
457 };
458 use anyhow::Result;
459 use std::fs;
460 use std::path::Path;
461
462 #[test]
463 fn parses_starter_config() -> Result<()> {
464 let config: Config = toml::from_str(STARTER_CONFIG)?;
465 validate_config(&config)?;
466 assert!(config.templates.contains_key("default"));
467 assert_eq!(config.settings.icons, IconMode::Auto);
468 assert_eq!(config.settings.icon_colors, IconColors::default());
469 Ok(())
470 }
471
472 #[test]
473 fn parses_starter_project() -> Result<()> {
474 let project: super::Project = toml::from_str(STARTER_PROJECT)?;
475 assert_eq!(project.session_name.as_deref(), Some("example"));
476 assert_eq!(project.template.as_deref(), Some("rust"));
477 Ok(())
478 }
479
480 #[test]
481 fn parses_inline_table_windows_and_panes() -> Result<()> {
482 let input = r#"
483[templates.default]
484startup_window = "main"
485windows = [
486 { name = "main" },
487 { name = "run", panes = [
488 { command = "cargo run" },
489 { layout = "right 40%", command = "cargo test" },
490 ] },
491]
492"#;
493
494 let config: Config = toml::from_str(input)?;
495 validate_config(&config)?;
496 assert_eq!(config.templates["default"].windows.len(), 2);
497 assert_eq!(
498 config.templates["default"].windows[1]
499 .panes
500 .as_ref()
501 .expect("panes should exist")
502 .len(),
503 2
504 );
505 Ok(())
506 }
507
508 #[test]
509 fn rejects_missing_project_template() {
510 let config = Config::default();
511 let project: super::Project =
512 toml::from_str("path = \"/tmp/demo\"\ntemplate = \"missing\"\n")
513 .expect("project should parse");
514 let error =
515 super::validate_project("demo", &project, &config).expect_err("validation should fail");
516 assert!(error.to_string().contains("referenced by project"));
517 }
518
519 #[test]
520 fn resolves_project_by_normalized_path() -> Result<()> {
521 let tempdir = tempfile::tempdir()?;
522 let config_path = tempdir.path().join("config.toml");
523 let project_dir = tempdir.path().join("projects");
524 let workspace_dir = tempdir.path().join("demo");
525 fs::create_dir(&workspace_dir)?;
526 fs::create_dir(&project_dir)?;
527
528 fs::write(
529 &config_path,
530 r#"
531[templates.default]
532windows = [{ name = "main" }]
533"#,
534 )?;
535 fs::write(
536 project_dir.join("demo.toml"),
537 format!(
538 "path = \"{}\"\ntemplate = \"default\"\n",
539 workspace_dir.display()
540 ),
541 )?;
542
543 let loaded = load_workspace(Some(&config_path))?;
544 let resolved =
545 resolve_project(&loaded, Path::new(&workspace_dir))?.expect("project should resolve");
546 assert_eq!(resolved.name, "demo");
547
548 Ok(())
549 }
550
551 #[test]
552 fn materializes_project_overrides_on_template() -> Result<()> {
553 let config: Config = toml::from_str(
554 r#"
555[templates.default]
556startup_window = "main"
557windows = [{ name = "main" }]
558"#,
559 )?;
560
561 let project: super::Project = toml::from_str(
562 r#"
563path = "/tmp/demo"
564template = "default"
565startup_window = "editor"
566windows = [{ name = "editor", command = "nvim" }]
567"#,
568 )?;
569
570 let materialized = materialize_project_template(&config, &project)?
571 .expect("project should materialize a template");
572 assert_eq!(materialized.startup_window.as_deref(), Some("editor"));
573 assert_eq!(materialized.windows[0].name, "editor");
574 Ok(())
575 }
576
577 #[test]
578 fn loads_from_disk_with_projects() -> Result<()> {
579 let tempdir = tempfile::tempdir()?;
580 let path = tempdir.path().join("config.toml");
581 let project_dir = tempdir.path().join("projects");
582 fs::create_dir(&project_dir)?;
583 fs::write(&path, STARTER_CONFIG)?;
584 fs::write(project_dir.join("example.toml"), STARTER_PROJECT)?;
585
586 let loaded = load(Some(&path))?;
587 assert_eq!(loaded.path, path);
588 assert!(loaded.projects.contains_key("example"));
589 Ok(())
590 }
591
592 #[test]
593 fn loads_projects_without_main_config() -> Result<()> {
594 let tempdir = tempfile::tempdir()?;
595 let path = tempdir.path().join("config.toml");
596 let project_dir = tempdir.path().join("projects");
597 fs::create_dir(&project_dir)?;
598 fs::write(
599 project_dir.join("example.toml"),
600 r#"
601path = "/tmp/example"
602session_name = "example"
603windows = [{ name = "main", command = "nvim" }]
604"#,
605 )?;
606
607 let loaded = load_optional(Some(&path))?.expect("workspace should load");
608 assert!(!loaded.config_exists);
609 assert!(loaded.projects.contains_key("example"));
610 Ok(())
611 }
612
613 #[test]
614 fn init_creates_project_directory_and_starter_project() -> Result<()> {
615 let tempdir = tempfile::tempdir()?;
616 let path = tempdir.path().join("config.toml");
617
618 let written = super::init(Some(&path))?;
619 assert_eq!(written, path);
620 assert!(tempdir.path().join("projects").is_dir());
621 assert!(
622 tempdir
623 .path()
624 .join("projects")
625 .join("example.toml")
626 .exists()
627 );
628 Ok(())
629 }
630
631 #[test]
632 fn uses_xdg_config_home_when_set() -> Result<()> {
633 let tempdir = tempfile::tempdir()?;
634 unsafe {
635 std::env::set_var("XDG_CONFIG_HOME", tempdir.path());
636 }
637
638 let path = super::default_config_path()?;
639 assert_eq!(path, tempdir.path().join("smux").join("config.toml"));
640 assert_eq!(
641 default_projects_dir()?,
642 tempdir.path().join("smux").join("projects")
643 );
644
645 unsafe {
646 std::env::remove_var("XDG_CONFIG_HOME");
647 }
648
649 Ok(())
650 }
651}