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