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