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