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[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}