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