Skip to main content

smux/
config.rs

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}