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
10pub const STARTER_CONFIG: &str = r#"[settings]
11default_template = "default"
12icons = "auto"
13
14[settings.icon_colors]
15session = 75
16directory = 108
17template = 179
18project = 81
19
20[templates.default]
21startup_window = "main"
22windows = [{ name = "main" }]
23
24[templates.rust]
25startup_window = "editor"
26startup_pane = 0
27windows = [
28  { name = "editor", pre_command = "source .venv/bin/activate", command = "nvim" },
29  { name = "run", synchronize = true, layout = "main-horizontal", panes = [
30      { command = "source .venv/bin/activate" },
31      { command = "cargo run" },
32      { layout = "right 40%", command = "cargo test" },
33    ] },
34]
35"#;
36
37pub const STARTER_PROJECT: &str = r#"path = "~/code/example"
38session_name = "example"
39template = "rust"
40"#;
41
42#[derive(Debug, Clone, Deserialize, Default)]
43pub struct Config {
44    #[serde(default)]
45    pub settings: Settings,
46    #[serde(default)]
47    pub templates: HashMap<String, Template>,
48}
49
50#[derive(Debug, Clone, Deserialize, Default)]
51pub struct Settings {
52    pub default_template: Option<String>,
53    #[serde(default)]
54    pub icons: IconMode,
55    #[serde(default)]
56    pub icon_colors: IconColors,
57}
58
59#[derive(Debug, Clone, Copy, Deserialize, Default, Eq, PartialEq)]
60#[serde(rename_all = "lowercase")]
61pub enum IconMode {
62    #[default]
63    Auto,
64    Always,
65    Never,
66}
67
68impl IconMode {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Auto => "auto",
72            Self::Always => "always",
73            Self::Never => "never",
74        }
75    }
76}
77
78#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
79pub struct IconColors {
80    pub session: u8,
81    pub directory: u8,
82    pub template: u8,
83    pub project: u8,
84}
85
86impl Default for IconColors {
87    fn default() -> Self {
88        Self {
89            session: 75,
90            directory: 108,
91            template: 179,
92            project: 81,
93        }
94    }
95}
96
97#[derive(Debug, Clone, Deserialize, Default)]
98pub struct Project {
99    pub path: String,
100    pub session_name: Option<String>,
101    pub template: Option<String>,
102    pub root: Option<String>,
103    pub startup_window: Option<String>,
104    pub startup_pane: Option<usize>,
105    pub windows: Option<Vec<Window>>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
109pub struct Template {
110    pub root: Option<String>,
111    pub startup_window: Option<String>,
112    pub startup_pane: Option<usize>,
113    pub windows: Vec<Window>,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct Window {
118    pub name: String,
119    pub cwd: Option<String>,
120    pub pre_command: Option<String>,
121    pub command: Option<String>,
122    pub layout: Option<String>,
123    #[serde(default)]
124    pub synchronize: bool,
125    pub panes: Option<Vec<Pane>>,
126}
127
128#[derive(Debug, Clone, Deserialize)]
129pub struct Pane {
130    pub layout: Option<String>,
131    pub command: Option<String>,
132    pub cwd: Option<String>,
133}
134
135#[derive(Debug, Clone)]
136pub struct LoadedConfig {
137    pub path: PathBuf,
138    pub config_exists: bool,
139    pub project_dir: PathBuf,
140    pub config: Config,
141    pub projects: HashMap<String, Project>,
142}
143
144#[derive(Debug, Clone)]
145pub struct ResolvedProject<'a> {
146    pub name: &'a str,
147    pub project: &'a Project,
148    pub normalized_path: PathBuf,
149}
150
151pub fn default_config_dir() -> Result<PathBuf> {
152    if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
153        Ok(PathBuf::from(config_home).join("smux"))
154    } else {
155        let home = std::env::var_os("HOME").context("could not resolve HOME for config path")?;
156        Ok(PathBuf::from(home).join(".config").join("smux"))
157    }
158}
159
160pub fn default_config_path() -> Result<PathBuf> {
161    Ok(default_config_dir()?.join("config.toml"))
162}
163
164pub fn default_projects_dir() -> Result<PathBuf> {
165    Ok(default_config_dir()?.join("projects"))
166}
167
168pub fn projects_dir_for_config_path(path: &Path) -> PathBuf {
169    path.parent()
170        .map(|parent| parent.join("projects"))
171        .unwrap_or_else(|| PathBuf::from("projects"))
172}
173
174pub fn load(path: Option<&Path>) -> Result<LoadedConfig> {
175    let path = match path {
176        Some(path) => path.to_path_buf(),
177        None => default_config_path()?,
178    };
179
180    if !path.exists() {
181        bail!("failed to read config {}", path.display());
182    }
183
184    load_workspace(Some(&path))
185}
186
187pub fn load_workspace(path: Option<&Path>) -> Result<LoadedConfig> {
188    let path = match path {
189        Some(path) => path.to_path_buf(),
190        None => default_config_path()?,
191    };
192    let project_dir = projects_dir_for_config_path(&path);
193    let config_exists = path.exists();
194
195    let config = if config_exists {
196        let text = fs::read_to_string(&path)
197            .with_context(|| format!("failed to read config {}", path.display()))?;
198        let config: Config = toml::from_str(&text)
199            .with_context(|| format!("failed to parse config {}", path.display()))?;
200        validate_config(&config)?;
201        config
202    } else {
203        Config::default()
204    };
205
206    let projects = load_projects(&project_dir, &config)?;
207
208    Ok(LoadedConfig {
209        path,
210        config_exists,
211        project_dir,
212        config,
213        projects,
214    })
215}
216
217pub fn load_optional(path: Option<&Path>) -> Result<Option<LoadedConfig>> {
218    let path = match path {
219        Some(path) => path.to_path_buf(),
220        None => default_config_path()?,
221    };
222    let project_dir = projects_dir_for_config_path(&path);
223
224    if !path.exists() && !project_dir.exists() {
225        return Ok(None);
226    }
227
228    load_workspace(Some(&path)).map(Some)
229}
230
231pub fn init(path: Option<&Path>) -> Result<PathBuf> {
232    let path = match path {
233        Some(path) => path.to_path_buf(),
234        None => default_config_path()?,
235    };
236
237    if path.exists() {
238        bail!("config already exists at {}", path.display());
239    }
240
241    let config_dir = path
242        .parent()
243        .context("config path did not have a parent directory")?;
244    let project_dir = config_dir.join("projects");
245
246    fs::create_dir_all(config_dir)
247        .with_context(|| format!("failed to create config directory {}", config_dir.display()))?;
248    fs::create_dir_all(&project_dir).with_context(|| {
249        format!(
250            "failed to create project directory {}",
251            project_dir.display()
252        )
253    })?;
254
255    fs::write(&path, STARTER_CONFIG)
256        .with_context(|| format!("failed to write starter config to {}", path.display()))?;
257
258    let starter_project_path = project_dir.join("example.toml");
259    fs::write(&starter_project_path, STARTER_PROJECT).with_context(|| {
260        format!(
261            "failed to write starter project to {}",
262            starter_project_path.display()
263        )
264    })?;
265
266    Ok(path)
267}
268
269pub fn validate_config(config: &Config) -> Result<()> {
270    for (template_name, template) in &config.templates {
271        validate_template(template_name, template)?;
272    }
273
274    if let Some(default_template) = &config.settings.default_template
275        && !config.templates.contains_key(default_template)
276    {
277        bail!("default_template \"{default_template}\" was not found");
278    }
279
280    Ok(())
281}
282
283fn validate_template(name: &str, template: &Template) -> Result<()> {
284    if template.windows.is_empty() {
285        bail!("{name} must contain at least one window");
286    }
287
288    if let Some(startup_window) = &template.startup_window
289        && !template
290            .windows
291            .iter()
292            .any(|window| window.name == *startup_window)
293    {
294        bail!("{name} references missing startup window \"{startup_window}\"");
295    }
296
297    for window in &template.windows {
298        validate_window(name, window)?;
299    }
300
301    Ok(())
302}
303
304fn validate_window(owner_name: &str, window: &Window) -> Result<()> {
305    if window.command.is_some() && window.panes.is_some() {
306        bail!(
307            "{owner_name} window \"{}\" cannot define both command and panes",
308            window.name
309        );
310    }
311
312    if let Some(panes) = &window.panes
313        && panes.is_empty()
314    {
315        bail!(
316            "{owner_name} window \"{}\" cannot define an empty panes array",
317            window.name
318        );
319    }
320
321    Ok(())
322}
323
324fn load_projects(project_dir: &Path, config: &Config) -> Result<HashMap<String, Project>> {
325    if !project_dir.exists() {
326        return Ok(HashMap::new());
327    }
328
329    let mut files = fs::read_dir(project_dir)
330        .with_context(|| format!("failed to read project directory {}", project_dir.display()))?
331        .collect::<std::io::Result<Vec<_>>>()
332        .with_context(|| format!("failed to read project directory {}", project_dir.display()))?;
333    files.sort_by_key(|entry| entry.file_name());
334
335    let mut projects = HashMap::new();
336
337    for entry in files {
338        let path = entry.path();
339        if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
340            continue;
341        }
342
343        let name = path
344            .file_stem()
345            .and_then(|stem| stem.to_str())
346            .context("project file name was not valid utf-8")?
347            .to_owned();
348
349        let text = fs::read_to_string(&path)
350            .with_context(|| format!("failed to read project {}", path.display()))?;
351        let project: Project = toml::from_str(&text)
352            .with_context(|| format!("failed to parse project {}", path.display()))?;
353        validate_project(&name, &project, config)?;
354        projects.insert(name, project);
355    }
356
357    Ok(projects)
358}
359
360fn validate_project(name: &str, project: &Project, config: &Config) -> Result<()> {
361    util::expand_and_absolutize_path(Path::new(&project.path))
362        .with_context(|| format!("project \"{name}\" has an invalid path {}", project.path))?;
363
364    if let Some(template_name) = &project.template
365        && !config.templates.contains_key(template_name)
366    {
367        bail!("template \"{template_name}\" referenced by project \"{name}\" was not found");
368    }
369
370    let has_direct_session_definition = project.root.is_some()
371        || project.startup_window.is_some()
372        || project.startup_pane.is_some()
373        || project.windows.is_some();
374
375    if has_direct_session_definition {
376        let effective = materialize_project_template(config, project)?
377            .context("project materialization unexpectedly returned no template")?;
378        validate_template(&format!("project \"{name}\""), &effective)?;
379    }
380
381    Ok(())
382}
383
384pub fn materialize_project_template(
385    config: &Config,
386    project: &Project,
387) -> Result<Option<Template>> {
388    let base = match &project.template {
389        Some(template_name) => Some(
390            config
391                .templates
392                .get(template_name)
393                .cloned()
394                .ok_or_else(|| anyhow::anyhow!("unknown template: {template_name}"))?,
395        ),
396        None => None,
397    };
398
399    let has_direct_session_definition = project.root.is_some()
400        || project.startup_window.is_some()
401        || project.startup_pane.is_some()
402        || project.windows.is_some();
403
404    if !has_direct_session_definition {
405        return Ok(base);
406    }
407
408    let mut effective = base.unwrap_or(Template {
409        root: None,
410        startup_window: None,
411        startup_pane: None,
412        windows: Vec::new(),
413    });
414
415    if let Some(root) = &project.root {
416        effective.root = Some(root.clone());
417    }
418    if let Some(startup_window) = &project.startup_window {
419        effective.startup_window = Some(startup_window.clone());
420    }
421    if let Some(startup_pane) = project.startup_pane {
422        effective.startup_pane = Some(startup_pane);
423    }
424    if let Some(windows) = &project.windows {
425        effective.windows = windows.clone();
426    }
427
428    Ok(Some(effective))
429}
430
431pub fn resolve_project<'a>(
432    loaded: &'a LoadedConfig,
433    path: &Path,
434) -> Result<Option<ResolvedProject<'a>>> {
435    let normalized = util::expand_and_normalize_path(path)?;
436
437    for (name, project) in &loaded.projects {
438        let project_path = util::expand_and_absolutize_path(Path::new(&project.path))?;
439        if project_path == normalized {
440            return Ok(Some(ResolvedProject {
441                name,
442                project,
443                normalized_path: project_path,
444            }));
445        }
446    }
447
448    Ok(None)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::{
454        Config, IconColors, IconMode, STARTER_CONFIG, STARTER_PROJECT, default_projects_dir, load,
455        load_optional, load_workspace, materialize_project_template, resolve_project,
456        validate_config,
457    };
458    use anyhow::Result;
459    use std::fs;
460    use std::path::Path;
461
462    #[test]
463    fn parses_starter_config() -> Result<()> {
464        let config: Config = toml::from_str(STARTER_CONFIG)?;
465        validate_config(&config)?;
466        assert!(config.templates.contains_key("default"));
467        assert_eq!(config.settings.icons, IconMode::Auto);
468        assert_eq!(config.settings.icon_colors, IconColors::default());
469        Ok(())
470    }
471
472    #[test]
473    fn parses_starter_project() -> Result<()> {
474        let project: super::Project = toml::from_str(STARTER_PROJECT)?;
475        assert_eq!(project.session_name.as_deref(), Some("example"));
476        assert_eq!(project.template.as_deref(), Some("rust"));
477        Ok(())
478    }
479
480    #[test]
481    fn parses_inline_table_windows_and_panes() -> Result<()> {
482        let input = r#"
483[templates.default]
484startup_window = "main"
485windows = [
486  { name = "main" },
487  { name = "run", panes = [
488      { command = "cargo run" },
489      { layout = "right 40%", command = "cargo test" },
490    ] },
491]
492"#;
493
494        let config: Config = toml::from_str(input)?;
495        validate_config(&config)?;
496        assert_eq!(config.templates["default"].windows.len(), 2);
497        assert_eq!(
498            config.templates["default"].windows[1]
499                .panes
500                .as_ref()
501                .expect("panes should exist")
502                .len(),
503            2
504        );
505        Ok(())
506    }
507
508    #[test]
509    fn rejects_missing_project_template() {
510        let config = Config::default();
511        let project: super::Project =
512            toml::from_str("path = \"/tmp/demo\"\ntemplate = \"missing\"\n")
513                .expect("project should parse");
514        let error =
515            super::validate_project("demo", &project, &config).expect_err("validation should fail");
516        assert!(error.to_string().contains("referenced by project"));
517    }
518
519    #[test]
520    fn resolves_project_by_normalized_path() -> Result<()> {
521        let tempdir = tempfile::tempdir()?;
522        let config_path = tempdir.path().join("config.toml");
523        let project_dir = tempdir.path().join("projects");
524        let workspace_dir = tempdir.path().join("demo");
525        fs::create_dir(&workspace_dir)?;
526        fs::create_dir(&project_dir)?;
527
528        fs::write(
529            &config_path,
530            r#"
531[templates.default]
532windows = [{ name = "main" }]
533"#,
534        )?;
535        fs::write(
536            project_dir.join("demo.toml"),
537            format!(
538                "path = \"{}\"\ntemplate = \"default\"\n",
539                workspace_dir.display()
540            ),
541        )?;
542
543        let loaded = load_workspace(Some(&config_path))?;
544        let resolved =
545            resolve_project(&loaded, Path::new(&workspace_dir))?.expect("project should resolve");
546        assert_eq!(resolved.name, "demo");
547
548        Ok(())
549    }
550
551    #[test]
552    fn materializes_project_overrides_on_template() -> Result<()> {
553        let config: Config = toml::from_str(
554            r#"
555[templates.default]
556startup_window = "main"
557windows = [{ name = "main" }]
558"#,
559        )?;
560
561        let project: super::Project = toml::from_str(
562            r#"
563path = "/tmp/demo"
564template = "default"
565startup_window = "editor"
566windows = [{ name = "editor", command = "nvim" }]
567"#,
568        )?;
569
570        let materialized = materialize_project_template(&config, &project)?
571            .expect("project should materialize a template");
572        assert_eq!(materialized.startup_window.as_deref(), Some("editor"));
573        assert_eq!(materialized.windows[0].name, "editor");
574        Ok(())
575    }
576
577    #[test]
578    fn loads_from_disk_with_projects() -> Result<()> {
579        let tempdir = tempfile::tempdir()?;
580        let path = tempdir.path().join("config.toml");
581        let project_dir = tempdir.path().join("projects");
582        fs::create_dir(&project_dir)?;
583        fs::write(&path, STARTER_CONFIG)?;
584        fs::write(project_dir.join("example.toml"), STARTER_PROJECT)?;
585
586        let loaded = load(Some(&path))?;
587        assert_eq!(loaded.path, path);
588        assert!(loaded.projects.contains_key("example"));
589        Ok(())
590    }
591
592    #[test]
593    fn loads_projects_without_main_config() -> Result<()> {
594        let tempdir = tempfile::tempdir()?;
595        let path = tempdir.path().join("config.toml");
596        let project_dir = tempdir.path().join("projects");
597        fs::create_dir(&project_dir)?;
598        fs::write(
599            project_dir.join("example.toml"),
600            r#"
601path = "/tmp/example"
602session_name = "example"
603windows = [{ name = "main", command = "nvim" }]
604"#,
605        )?;
606
607        let loaded = load_optional(Some(&path))?.expect("workspace should load");
608        assert!(!loaded.config_exists);
609        assert!(loaded.projects.contains_key("example"));
610        Ok(())
611    }
612
613    #[test]
614    fn init_creates_project_directory_and_starter_project() -> Result<()> {
615        let tempdir = tempfile::tempdir()?;
616        let path = tempdir.path().join("config.toml");
617
618        let written = super::init(Some(&path))?;
619        assert_eq!(written, path);
620        assert!(tempdir.path().join("projects").is_dir());
621        assert!(
622            tempdir
623                .path()
624                .join("projects")
625                .join("example.toml")
626                .exists()
627        );
628        Ok(())
629    }
630
631    #[test]
632    fn uses_xdg_config_home_when_set() -> Result<()> {
633        let tempdir = tempfile::tempdir()?;
634        unsafe {
635            std::env::set_var("XDG_CONFIG_HOME", tempdir.path());
636        }
637
638        let path = super::default_config_path()?;
639        assert_eq!(path, tempdir.path().join("smux").join("config.toml"));
640        assert_eq!(
641            default_projects_dir()?,
642            tempdir.path().join("smux").join("projects")
643        );
644
645        unsafe {
646            std::env::remove_var("XDG_CONFIG_HOME");
647        }
648
649        Ok(())
650    }
651}