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 MAX_FOLDER_SEARCH_DEPTH: usize = 16;
11
12const STARTER_CONFIG_BODY: &str = r#"[settings]
13default_template = "default"
14icons = "auto"
15
16[settings.icon_colors]
17session = 75
18directory = 108
19template = 179
20project = 81
21
22[settings.picker.bindings]
23reset = "ctrl-c"
24sessions = "ctrl-s"
25folders = "ctrl-f"
26projects = "ctrl-p"
27delete_session = "ctrl-x"
28save_project = "ctrl-y"
29
30[settings.picker.preview]
31# sessions = "tmux capture-pane -p -t \"$SMUX_PREVIEW_SESSION\""
32# folders = "eza --tree --level=2 --color=always --icons=always \"$SMUX_PREVIEW_PATH\""
33# projects = "bat --style=plain --color=always --language=toml \"$SMUX_PREVIEW_FILE\""
34
35[settings.folder_search]
36# roots = ["~"]
37# max_depth = 3
38# include_hidden = false
39
40[templates.default]
41startup_window = "main"
42windows = [{ name = "main" }]
43
44[templates.rust]
45startup_window = "editor"
46startup_pane = 0
47windows = [
48  { name = "editor", pre_command = "source .venv/bin/activate", command = "nvim" },
49  { name = "run", synchronize = true, layout = "main-horizontal", panes = [
50      { command = "source .venv/bin/activate" },
51      { layout = "bottom", command = "cargo run" },
52      { layout = "right 40%", command = "cargo test" },
53    ] },
54]
55"#;
56
57const STARTER_PROJECT_BODY: &str = r#"path = "~/code/example"
58session_name = "example"
59template = "rust"
60"#;
61
62#[derive(Debug, Clone, Deserialize, Default)]
63#[serde(deny_unknown_fields)]
64pub struct Config {
65    #[serde(default)]
66    pub settings: Settings,
67    #[serde(default)]
68    pub templates: HashMap<String, Template>,
69}
70
71#[derive(Debug, Clone, Deserialize, Default)]
72#[serde(deny_unknown_fields)]
73pub struct Settings {
74    pub default_template: Option<String>,
75    #[serde(default)]
76    pub icons: IconMode,
77    #[serde(default)]
78    pub icon_colors: IconColors,
79    #[serde(default)]
80    pub picker: PickerSettings,
81    #[serde(default)]
82    pub folder_search: FolderSearchSettings,
83}
84
85#[derive(Debug, Clone, Copy, Deserialize, Default, Eq, PartialEq)]
86#[serde(rename_all = "lowercase")]
87pub enum IconMode {
88    #[default]
89    Auto,
90    Always,
91    Never,
92}
93
94impl IconMode {
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Self::Auto => "auto",
98            Self::Always => "always",
99            Self::Never => "never",
100        }
101    }
102}
103
104#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
105#[serde(deny_unknown_fields)]
106pub struct IconColors {
107    pub session: u8,
108    pub directory: u8,
109    pub template: u8,
110    pub project: u8,
111}
112
113impl Default for IconColors {
114    fn default() -> Self {
115        Self {
116            session: 75,
117            directory: 108,
118            template: 179,
119            project: 81,
120        }
121    }
122}
123
124#[derive(Debug, Clone, Deserialize, Default, Eq, PartialEq)]
125#[serde(deny_unknown_fields)]
126pub struct PickerSettings {
127    #[serde(default)]
128    pub bindings: PickerBindings,
129    #[serde(default)]
130    pub preview: PickerPreviewSettings,
131}
132
133#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
134#[serde(deny_unknown_fields)]
135pub struct PickerBindings {
136    #[serde(default = "default_picker_reset")]
137    pub reset: String,
138    #[serde(default = "default_picker_sessions")]
139    pub sessions: String,
140    #[serde(default = "default_picker_folders")]
141    pub folders: String,
142    #[serde(default = "default_picker_projects")]
143    pub projects: String,
144    #[serde(default = "default_picker_delete_session")]
145    pub delete_session: String,
146    #[serde(default = "default_picker_save_project")]
147    pub save_project: String,
148}
149
150#[derive(Debug, Clone, Deserialize, Default, Eq, PartialEq)]
151#[serde(deny_unknown_fields)]
152pub struct PickerPreviewSettings {
153    pub folders: Option<String>,
154    pub sessions: Option<String>,
155    pub projects: Option<String>,
156}
157
158#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
159#[serde(deny_unknown_fields)]
160pub struct FolderSearchSettings {
161    #[serde(default = "default_folder_search_roots")]
162    pub roots: Vec<String>,
163    #[serde(default = "default_folder_search_max_depth")]
164    pub max_depth: usize,
165    #[serde(default)]
166    pub include_hidden: bool,
167}
168
169impl Default for FolderSearchSettings {
170    fn default() -> Self {
171        Self {
172            roots: default_folder_search_roots(),
173            max_depth: default_folder_search_max_depth(),
174            include_hidden: false,
175        }
176    }
177}
178
179fn default_folder_search_roots() -> Vec<String> {
180    vec!["~".to_owned()]
181}
182
183fn default_folder_search_max_depth() -> usize {
184    3
185}
186
187impl Default for PickerBindings {
188    fn default() -> Self {
189        Self {
190            reset: default_picker_reset(),
191            sessions: default_picker_sessions(),
192            folders: default_picker_folders(),
193            projects: default_picker_projects(),
194            delete_session: default_picker_delete_session(),
195            save_project: default_picker_save_project(),
196        }
197    }
198}
199
200fn default_picker_reset() -> String {
201    "ctrl-c".to_owned()
202}
203
204fn default_picker_sessions() -> String {
205    "ctrl-s".to_owned()
206}
207
208fn default_picker_folders() -> String {
209    "ctrl-f".to_owned()
210}
211
212fn default_picker_projects() -> String {
213    "ctrl-p".to_owned()
214}
215
216fn default_picker_delete_session() -> String {
217    "ctrl-x".to_owned()
218}
219
220fn default_picker_save_project() -> String {
221    "ctrl-y".to_owned()
222}
223
224#[derive(Debug, Clone, Deserialize, Default)]
225#[serde(deny_unknown_fields)]
226pub struct Project {
227    pub path: String,
228    pub session_name: Option<String>,
229    pub template: Option<String>,
230    pub root: Option<String>,
231    pub startup_window: Option<String>,
232    pub startup_pane: Option<usize>,
233    pub windows: Option<Vec<Window>>,
234}
235
236#[derive(Debug, Clone, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct Template {
239    pub root: Option<String>,
240    pub startup_window: Option<String>,
241    pub startup_pane: Option<usize>,
242    pub windows: Vec<Window>,
243}
244
245#[derive(Debug, Clone, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct Window {
248    pub name: String,
249    pub cwd: Option<String>,
250    pub pre_command: Option<String>,
251    pub command: Option<String>,
252    pub layout: Option<String>,
253    #[serde(default)]
254    pub synchronize: bool,
255    pub panes: Option<Vec<Pane>>,
256}
257
258#[derive(Debug, Clone, Deserialize)]
259#[serde(deny_unknown_fields)]
260pub struct Pane {
261    pub layout: Option<String>,
262    pub command: Option<String>,
263    pub cwd: Option<String>,
264    #[serde(default)]
265    pub zoom: bool,
266}
267
268#[derive(Debug, Clone)]
269pub struct LoadedConfig {
270    pub path: PathBuf,
271    pub config_exists: bool,
272    pub project_dir: PathBuf,
273    pub config: Config,
274    pub projects: HashMap<String, Project>,
275    pub project_files: HashMap<String, PathBuf>,
276    pub invalid_projects: Vec<InvalidProject>,
277}
278
279#[derive(Debug, Clone)]
280pub struct ResolvedProject<'a> {
281    pub name: &'a str,
282    pub project: &'a Project,
283    pub normalized_path: PathBuf,
284}
285
286#[derive(Debug, Clone)]
287pub struct InvalidProject {
288    pub name: String,
289    pub path: PathBuf,
290    pub error: String,
291}
292
293type LoadedProjects = (
294    HashMap<String, Project>,
295    HashMap<String, PathBuf>,
296    Vec<InvalidProject>,
297);
298
299pub fn starter_config() -> String {
300    format!(
301        "#:schema {}\n{}",
302        schema_url("smux-config.schema.json"),
303        STARTER_CONFIG_BODY
304    )
305}
306
307pub fn starter_project() -> String {
308    format!(
309        "#:schema {}\n{}",
310        schema_url("smux-project.schema.json"),
311        STARTER_PROJECT_BODY
312    )
313}
314
315pub fn schema_url(filename: &str) -> String {
316    format!(
317        "https://raw.githubusercontent.com/Aietes/smux/v{}/schemas/{filename}",
318        env!("CARGO_PKG_VERSION")
319    )
320}
321
322pub fn default_config_dir() -> Result<PathBuf> {
323    if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
324        Ok(PathBuf::from(config_home).join("smux"))
325    } else {
326        let home = std::env::var_os("HOME").context("could not resolve HOME for config path")?;
327        Ok(PathBuf::from(home).join(".config").join("smux"))
328    }
329}
330
331pub fn default_config_path() -> Result<PathBuf> {
332    Ok(default_config_dir()?.join("config.toml"))
333}
334
335pub fn default_projects_dir() -> Result<PathBuf> {
336    Ok(default_config_dir()?.join("projects"))
337}
338
339pub fn projects_dir_for_config_path(path: &Path) -> PathBuf {
340    path.parent()
341        .map(|parent| parent.join("projects"))
342        .unwrap_or_else(|| PathBuf::from("projects"))
343}
344
345pub fn load(path: Option<&Path>) -> Result<LoadedConfig> {
346    let path = match path {
347        Some(path) => path.to_path_buf(),
348        None => default_config_path()?,
349    };
350
351    if !path.exists() {
352        bail!("failed to read config {}", path.display());
353    }
354
355    load_workspace(Some(&path))
356}
357
358pub fn load_workspace(path: Option<&Path>) -> Result<LoadedConfig> {
359    let path = match path {
360        Some(path) => path.to_path_buf(),
361        None => default_config_path()?,
362    };
363    let project_dir = projects_dir_for_config_path(&path);
364    let config_exists = path.exists();
365
366    let config = if config_exists {
367        let text = fs::read_to_string(&path)
368            .with_context(|| format!("failed to read config {}", path.display()))?;
369        let config: Config = toml::from_str(&text)
370            .with_context(|| format!("failed to parse config {}", path.display()))?;
371        validate_config(&config)?;
372        config
373    } else {
374        Config::default()
375    };
376
377    let (projects, project_files, invalid_projects) = load_projects(&project_dir, &config)?;
378
379    Ok(LoadedConfig {
380        path,
381        config_exists,
382        project_dir,
383        config,
384        projects,
385        project_files,
386        invalid_projects,
387    })
388}
389
390pub fn load_optional(path: Option<&Path>) -> Result<Option<LoadedConfig>> {
391    let path = match path {
392        Some(path) => path.to_path_buf(),
393        None => default_config_path()?,
394    };
395    let project_dir = projects_dir_for_config_path(&path);
396
397    if !path.exists() && !project_dir.exists() {
398        return Ok(None);
399    }
400
401    load_workspace(Some(&path)).map(Some)
402}
403
404pub fn init(path: Option<&Path>) -> Result<PathBuf> {
405    let path = match path {
406        Some(path) => path.to_path_buf(),
407        None => default_config_path()?,
408    };
409
410    if path.exists() {
411        bail!("config already exists at {}", path.display());
412    }
413
414    let config_dir = path
415        .parent()
416        .context("config path did not have a parent directory")?;
417    let project_dir = config_dir.join("projects");
418
419    fs::create_dir_all(config_dir)
420        .with_context(|| format!("failed to create config directory {}", config_dir.display()))?;
421    fs::create_dir_all(&project_dir).with_context(|| {
422        format!(
423            "failed to create project directory {}",
424            project_dir.display()
425        )
426    })?;
427
428    fs::write(&path, starter_config())
429        .with_context(|| format!("failed to write starter config to {}", path.display()))?;
430
431    let starter_project_path = project_dir.join("example.toml");
432    fs::write(&starter_project_path, starter_project()).with_context(|| {
433        format!(
434            "failed to write starter project to {}",
435            starter_project_path.display()
436        )
437    })?;
438
439    Ok(path)
440}
441
442pub fn validate_config(config: &Config) -> Result<()> {
443    validate_picker_bindings(&config.settings.picker.bindings)?;
444    validate_folder_search(&config.settings.folder_search)?;
445
446    for (template_name, template) in &config.templates {
447        validate_template(template_name, template)?;
448    }
449
450    if let Some(default_template) = &config.settings.default_template
451        && !config.templates.contains_key(default_template)
452    {
453        bail!("default_template \"{default_template}\" was not found");
454    }
455
456    Ok(())
457}
458
459fn validate_folder_search(settings: &FolderSearchSettings) -> Result<()> {
460    if settings.max_depth > MAX_FOLDER_SEARCH_DEPTH {
461        bail!(
462            "folder_search.max_depth must be at most {}",
463            MAX_FOLDER_SEARCH_DEPTH
464        );
465    }
466
467    for root in &settings.roots {
468        if root.trim().is_empty() {
469            bail!("folder_search.roots must not contain empty paths");
470        }
471    }
472
473    Ok(())
474}
475
476fn validate_picker_bindings(bindings: &PickerBindings) -> Result<()> {
477    let values = [
478        ("reset", bindings.reset.trim()),
479        ("sessions", bindings.sessions.trim()),
480        ("folders", bindings.folders.trim()),
481        ("projects", bindings.projects.trim()),
482        ("delete_session", bindings.delete_session.trim()),
483        ("save_project", bindings.save_project.trim()),
484    ];
485
486    for (name, value) in values {
487        if value.is_empty() {
488            bail!("picker binding \"{name}\" must not be empty");
489        }
490    }
491
492    let mut seen = std::collections::HashSet::new();
493    for (name, value) in values {
494        if !seen.insert(value) {
495            bail!("picker binding \"{name}\" duplicates another picker binding");
496        }
497    }
498
499    Ok(())
500}
501
502fn validate_template(name: &str, template: &Template) -> Result<()> {
503    if template.windows.is_empty() {
504        bail!("{name} must contain at least one window");
505    }
506
507    if let Some(startup_window) = &template.startup_window
508        && !template
509            .windows
510            .iter()
511            .any(|window| window.name == *startup_window)
512    {
513        bail!("{name} references missing startup window \"{startup_window}\"");
514    }
515
516    validate_startup_pane(name, template)?;
517
518    for window in &template.windows {
519        validate_window(name, window)?;
520    }
521
522    Ok(())
523}
524
525fn validate_startup_pane(owner_name: &str, template: &Template) -> Result<()> {
526    let startup_pane = template.startup_pane.unwrap_or(0);
527    let startup_window = template
528        .startup_window
529        .as_deref()
530        .unwrap_or(&template.windows[0].name);
531    let window = template
532        .windows
533        .iter()
534        .find(|window| window.name == startup_window)
535        .context("startup window validation ran before startup window existence validation")?;
536    let pane_count = window.panes.as_ref().map(Vec::len).unwrap_or(1);
537
538    if startup_pane >= pane_count {
539        bail!(
540            "{owner_name} startup_pane {} is out of range for window \"{}\" with {} pane(s)",
541            startup_pane,
542            startup_window,
543            pane_count
544        );
545    }
546
547    Ok(())
548}
549
550fn validate_window(owner_name: &str, window: &Window) -> Result<()> {
551    if window.command.is_some() && window.panes.is_some() {
552        bail!(
553            "{owner_name} window \"{}\" cannot define both command and panes",
554            window.name
555        );
556    }
557
558    if let Some(panes) = &window.panes
559        && panes.is_empty()
560    {
561        bail!(
562            "{owner_name} window \"{}\" cannot define an empty panes array",
563            window.name
564        );
565    }
566
567    if let Some(panes) = &window.panes {
568        for (index, pane) in panes.iter().enumerate() {
569            if index > 0 && pane.layout.is_none() {
570                bail!(
571                    "{owner_name} pane {} in window \"{}\" is missing a layout",
572                    index,
573                    window.name
574                );
575            }
576
577            if let Some(layout) = &pane.layout {
578                crate::templates::validate_pane_layout(layout).with_context(|| {
579                    format!(
580                        "{owner_name} pane {} in window \"{}\" has an invalid layout",
581                        index, window.name
582                    )
583                })?;
584            }
585        }
586
587        let zoomed = panes.iter().filter(|pane| pane.zoom).count();
588        if zoomed > 1 {
589            bail!(
590                "{owner_name} window \"{}\" may define at most one zoomed pane",
591                window.name
592            );
593        }
594    }
595
596    Ok(())
597}
598
599fn load_projects(project_dir: &Path, config: &Config) -> Result<LoadedProjects> {
600    if !project_dir.exists() {
601        return Ok((HashMap::new(), HashMap::new(), Vec::new()));
602    }
603
604    let mut files = fs::read_dir(project_dir)
605        .with_context(|| format!("failed to read project directory {}", project_dir.display()))?
606        .collect::<std::io::Result<Vec<_>>>()
607        .with_context(|| format!("failed to read project directory {}", project_dir.display()))?;
608    files.sort_by_key(|entry| entry.file_name());
609
610    let mut projects = HashMap::new();
611    let mut project_files = HashMap::new();
612    let mut invalid_projects = Vec::new();
613
614    for entry in files {
615        let path = entry.path();
616        if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
617            continue;
618        }
619
620        let name = path
621            .file_stem()
622            .and_then(|stem| stem.to_str())
623            .context("project file name was not valid utf-8")?
624            .to_owned();
625
626        match load_project_file(&path, &name, config) {
627            Ok(project) => {
628                project_files.insert(name.clone(), path.clone());
629                projects.insert(name, project);
630            }
631            Err(error) => invalid_projects.push(InvalidProject {
632                name,
633                path: path.clone(),
634                error: error.to_string(),
635            }),
636        }
637    }
638
639    Ok((projects, project_files, invalid_projects))
640}
641
642fn load_project_file(path: &Path, name: &str, config: &Config) -> Result<Project> {
643    let text = fs::read_to_string(path)
644        .with_context(|| format!("failed to read project {}", path.display()))?;
645    let project: Project = toml::from_str(&text)
646        .with_context(|| format!("failed to parse project {}", path.display()))?;
647    validate_project(name, &project, config)?;
648    Ok(project)
649}
650
651fn validate_project(name: &str, project: &Project, config: &Config) -> Result<()> {
652    util::expand_and_absolutize_path(Path::new(&project.path))
653        .with_context(|| format!("project \"{name}\" has an invalid path {}", project.path))?;
654
655    if let Some(template_name) = &project.template
656        && !config.templates.contains_key(template_name)
657    {
658        bail!("template \"{template_name}\" referenced by project \"{name}\" was not found");
659    }
660
661    let has_direct_session_definition = project.root.is_some()
662        || project.startup_window.is_some()
663        || project.startup_pane.is_some()
664        || project.windows.is_some();
665
666    if has_direct_session_definition {
667        let effective = materialize_project_template(config, project)?
668            .context("project materialization unexpectedly returned no template")?;
669        validate_template(&format!("project \"{name}\""), &effective)?;
670    }
671
672    Ok(())
673}
674
675pub fn materialize_project_template(
676    config: &Config,
677    project: &Project,
678) -> Result<Option<Template>> {
679    let base = match &project.template {
680        Some(template_name) => Some(
681            config
682                .templates
683                .get(template_name)
684                .cloned()
685                .ok_or_else(|| anyhow::anyhow!("unknown template: {template_name}"))?,
686        ),
687        None => None,
688    };
689
690    let has_direct_session_definition = project.root.is_some()
691        || project.startup_window.is_some()
692        || project.startup_pane.is_some()
693        || project.windows.is_some();
694
695    if !has_direct_session_definition {
696        return Ok(base);
697    }
698
699    let mut effective = base.unwrap_or(Template {
700        root: None,
701        startup_window: None,
702        startup_pane: None,
703        windows: Vec::new(),
704    });
705
706    if let Some(root) = &project.root {
707        effective.root = Some(root.clone());
708    }
709    if let Some(startup_window) = &project.startup_window {
710        effective.startup_window = Some(startup_window.clone());
711    }
712    if let Some(startup_pane) = project.startup_pane {
713        effective.startup_pane = Some(startup_pane);
714    }
715    if let Some(windows) = &project.windows {
716        effective.windows = windows.clone();
717    }
718
719    Ok(Some(effective))
720}
721
722pub fn resolve_project<'a>(
723    loaded: &'a LoadedConfig,
724    path: &Path,
725) -> Result<Option<ResolvedProject<'a>>> {
726    let normalized = util::expand_and_normalize_path(path)?;
727
728    for (name, project) in &loaded.projects {
729        let project_path = util::expand_and_absolutize_path(Path::new(&project.path))?;
730        if project_path == normalized {
731            return Ok(Some(ResolvedProject {
732                name,
733                project,
734                normalized_path: project_path,
735            }));
736        }
737    }
738
739    Ok(None)
740}
741
742pub fn delete_project_file(loaded: &LoadedConfig, project_name: &str) -> Result<PathBuf> {
743    let project_name = util::validated_project_name(project_name)?;
744    let path = loaded
745        .project_files
746        .get(&project_name)
747        .cloned()
748        .or_else(|| {
749            loaded
750                .invalid_projects
751                .iter()
752                .find(|project| project.name == project_name)
753                .map(|project| project.path.clone())
754        })
755        .with_context(|| format!("project file not found for {project_name}"))?;
756    ensure_project_file_is_in_project_dir(&loaded.project_dir, &path)?;
757    fs::remove_file(&path)
758        .with_context(|| format!("failed to delete project file {}", path.display()))?;
759    Ok(path)
760}
761
762fn ensure_project_file_is_in_project_dir(project_dir: &Path, path: &Path) -> Result<()> {
763    let project_dir = project_dir.canonicalize().with_context(|| {
764        format!(
765            "failed to resolve project directory {}",
766            project_dir.display()
767        )
768    })?;
769    let parent = path
770        .parent()
771        .with_context(|| format!("project file {} did not have a parent", path.display()))?
772        .canonicalize()
773        .with_context(|| format!("failed to resolve project file parent {}", path.display()))?;
774
775    if parent != project_dir {
776        bail!(
777            "refusing to delete project file outside project directory: {}",
778            path.display()
779        );
780    }
781
782    Ok(())
783}
784
785#[cfg(test)]
786mod tests {
787    use super::{
788        Config, IconColors, IconMode, PickerBindings, default_projects_dir, load, load_optional,
789        load_workspace, materialize_project_template, resolve_project, schema_url, starter_config,
790        starter_project, validate_config,
791    };
792    use anyhow::Result;
793    use std::fs;
794    use std::path::Path;
795
796    fn strip_schema_directive(text: &str) -> String {
797        text.lines().skip(1).collect::<Vec<_>>().join("\n")
798    }
799
800    #[test]
801    fn parses_starter_config() -> Result<()> {
802        let starter = starter_config();
803        assert!(starter.starts_with("#:schema "));
804        let config: Config = toml::from_str(&strip_schema_directive(&starter))?;
805        validate_config(&config)?;
806        assert!(config.templates.contains_key("default"));
807        assert_eq!(config.settings.icons, IconMode::Auto);
808        assert_eq!(config.settings.icon_colors, IconColors::default());
809        assert_eq!(config.settings.picker.bindings, PickerBindings::default());
810        assert_eq!(
811            config.settings.folder_search,
812            super::FolderSearchSettings::default()
813        );
814        Ok(())
815    }
816
817    #[test]
818    fn parses_starter_project() -> Result<()> {
819        let starter = starter_project();
820        assert!(starter.starts_with("#:schema "));
821        let project: super::Project = toml::from_str(&strip_schema_directive(&starter))?;
822        assert_eq!(project.session_name.as_deref(), Some("example"));
823        assert_eq!(project.template.as_deref(), Some("rust"));
824        Ok(())
825    }
826
827    #[test]
828    fn schema_urls_are_versioned() {
829        let version = env!("CARGO_PKG_VERSION");
830        assert!(schema_url("smux-config.schema.json").contains(&format!("/v{version}/")));
831        assert!(schema_url("smux-project.schema.json").contains(&format!("/v{version}/")));
832    }
833
834    #[test]
835    fn parses_custom_picker_bindings() -> Result<()> {
836        let input = r#"
837[settings.picker.bindings]
838reset = "alt-a"
839sessions = "alt-s"
840folders = "alt-f"
841projects = "alt-p"
842delete_session = "alt-x"
843save_project = "alt-y"
844"#;
845
846        let config: Config = toml::from_str(input)?;
847        validate_config(&config)?;
848        assert_eq!(config.settings.picker.bindings.reset, "alt-a");
849        assert_eq!(config.settings.picker.bindings.delete_session, "alt-x");
850        assert_eq!(config.settings.picker.bindings.save_project, "alt-y");
851        Ok(())
852    }
853
854    #[test]
855    fn parses_custom_picker_preview_commands() -> Result<()> {
856        let input = r#"
857[settings.picker.preview]
858sessions = "tmux capture-pane -p -t \"$SMUX_PREVIEW_SESSION\""
859folders = "eza --tree \"$SMUX_PREVIEW_PATH\""
860projects = "bat --style=plain \"$SMUX_PREVIEW_FILE\""
861"#;
862
863        let config: Config = toml::from_str(input)?;
864        assert_eq!(
865            config.settings.picker.preview.sessions.as_deref(),
866            Some("tmux capture-pane -p -t \"$SMUX_PREVIEW_SESSION\"")
867        );
868        assert_eq!(
869            config.settings.picker.preview.folders.as_deref(),
870            Some("eza --tree \"$SMUX_PREVIEW_PATH\"")
871        );
872        assert_eq!(
873            config.settings.picker.preview.projects.as_deref(),
874            Some("bat --style=plain \"$SMUX_PREVIEW_FILE\"")
875        );
876        Ok(())
877    }
878
879    #[test]
880    fn rejects_duplicate_picker_bindings() {
881        let input = r#"
882[settings.picker.bindings]
883reset = "ctrl-c"
884sessions = "ctrl-s"
885folders = "ctrl-f"
886projects = "ctrl-s"
887delete_session = "ctrl-x"
888save_project = "ctrl-y"
889"#;
890
891        let config: Config = toml::from_str(input).expect("config should parse");
892        let error = validate_config(&config).expect_err("duplicate picker bindings should fail");
893        assert!(
894            error
895                .to_string()
896                .contains("duplicates another picker binding")
897        );
898    }
899
900    #[test]
901    fn defaults_folder_search_to_home_root() -> Result<()> {
902        let config: Config = toml::from_str("[settings]\n")?;
903        assert_eq!(config.settings.folder_search.roots, vec!["~"]);
904        assert_eq!(config.settings.folder_search.max_depth, 3);
905        assert!(!config.settings.folder_search.include_hidden);
906        Ok(())
907    }
908
909    #[test]
910    fn parses_custom_folder_search_settings() -> Result<()> {
911        let input = r#"
912[settings.folder_search]
913roots = ["~/Development", "~/code"]
914max_depth = 5
915include_hidden = true
916"#;
917
918        let config: Config = toml::from_str(input)?;
919        validate_config(&config)?;
920        assert_eq!(
921            config.settings.folder_search.roots,
922            vec!["~/Development", "~/code"]
923        );
924        assert_eq!(config.settings.folder_search.max_depth, 5);
925        assert!(config.settings.folder_search.include_hidden);
926        Ok(())
927    }
928
929    #[test]
930    fn rejects_empty_folder_search_roots() {
931        let input = r#"
932[settings.folder_search]
933roots = [""]
934"#;
935
936        let config: Config = toml::from_str(input).expect("config should parse");
937        let error = validate_config(&config).expect_err("validation should fail");
938        assert!(error.to_string().contains("must not contain empty paths"));
939    }
940
941    #[test]
942    fn rejects_unbounded_folder_search_depth() {
943        let input = r#"
944[settings.folder_search]
945max_depth = 17
946"#;
947
948        let config: Config = toml::from_str(input).expect("config should parse");
949        let error = validate_config(&config).expect_err("validation should fail");
950        assert!(error.to_string().contains("max_depth"));
951    }
952
953    #[test]
954    fn parses_inline_table_windows_and_panes() -> Result<()> {
955        let input = r#"
956[templates.default]
957startup_window = "main"
958windows = [
959  { name = "main" },
960  { name = "run", panes = [
961      { command = "cargo run" },
962      { layout = "right 40%", command = "cargo test" },
963    ] },
964]
965"#;
966
967        let config: Config = toml::from_str(input)?;
968        validate_config(&config)?;
969        assert_eq!(config.templates["default"].windows.len(), 2);
970        assert_eq!(
971            config.templates["default"].windows[1]
972                .panes
973                .as_ref()
974                .expect("panes should exist")
975                .len(),
976            2
977        );
978        Ok(())
979    }
980
981    #[test]
982    fn rejects_missing_project_template() {
983        let config = Config::default();
984        let project: super::Project =
985            toml::from_str("path = \"/tmp/demo\"\ntemplate = \"missing\"\n")
986                .expect("project should parse");
987        let error =
988            super::validate_project("demo", &project, &config).expect_err("validation should fail");
989        assert!(error.to_string().contains("referenced by project"));
990    }
991
992    #[test]
993    fn rejects_unknown_project_fields() {
994        let error = toml::from_str::<super::Project>(
995            "path = \"/tmp/demo\"\nwindows = [{ name = \"main\", panes = [{ cmd = \"nvim\" }] }]\n",
996        )
997        .expect_err("unknown fields should fail");
998
999        assert!(error.to_string().contains("unknown field"));
1000        assert!(error.to_string().contains("cmd"));
1001    }
1002
1003    #[test]
1004    fn rejects_multiple_zoomed_panes_in_window() {
1005        let config: Config = toml::from_str(
1006            r#"
1007[templates.default]
1008windows = [
1009  { name = "main", panes = [
1010      { command = "nvim", zoom = true },
1011      { layout = "right", command = "cargo test", zoom = true },
1012    ] },
1013]
1014"#,
1015        )
1016        .expect("config should parse");
1017
1018        let error = validate_config(&config).expect_err("validation should fail");
1019        assert!(error.to_string().contains("zoomed pane"));
1020    }
1021
1022    #[test]
1023    fn rejects_startup_pane_out_of_range_during_config_validation() {
1024        let config: Config = toml::from_str(
1025            r#"
1026[templates.default]
1027startup_window = "main"
1028startup_pane = 2
1029windows = [
1030  { name = "main", panes = [
1031      { command = "nvim" },
1032      { layout = "right", command = "cargo test" },
1033    ] },
1034]
1035"#,
1036        )
1037        .expect("config should parse");
1038
1039        let error = validate_config(&config).expect_err("validation should fail");
1040        assert!(error.to_string().contains("startup_pane"));
1041        assert!(error.to_string().contains("out of range"));
1042    }
1043
1044    #[test]
1045    fn rejects_invalid_pane_layout_during_config_validation() {
1046        let config: Config = toml::from_str(
1047            r#"
1048[templates.default]
1049windows = [
1050  { name = "main", panes = [
1051      { command = "nvim" },
1052      { layout = "diagonal 40%", command = "cargo test" },
1053    ] },
1054]
1055"#,
1056        )
1057        .expect("config should parse");
1058
1059        let error = validate_config(&config).expect_err("validation should fail");
1060        assert!(error.to_string().contains("invalid layout"));
1061    }
1062
1063    #[test]
1064    fn rejects_missing_layout_for_additional_panes() {
1065        let config: Config = toml::from_str(
1066            r#"
1067[templates.default]
1068windows = [
1069  { name = "main", panes = [
1070      { command = "nvim" },
1071      { command = "cargo test" },
1072    ] },
1073]
1074"#,
1075        )
1076        .expect("config should parse");
1077
1078        let error = validate_config(&config).expect_err("validation should fail");
1079        assert!(error.to_string().contains("missing a layout"));
1080    }
1081
1082    #[test]
1083    fn resolves_project_by_normalized_path() -> Result<()> {
1084        let tempdir = tempfile::tempdir()?;
1085        let config_path = tempdir.path().join("config.toml");
1086        let project_dir = tempdir.path().join("projects");
1087        let workspace_dir = tempdir.path().join("demo");
1088        fs::create_dir(&workspace_dir)?;
1089        fs::create_dir(&project_dir)?;
1090
1091        fs::write(
1092            &config_path,
1093            r#"
1094[templates.default]
1095windows = [{ name = "main" }]
1096"#,
1097        )?;
1098        fs::write(
1099            project_dir.join("demo.toml"),
1100            format!(
1101                "path = \"{}\"\ntemplate = \"default\"\n",
1102                workspace_dir.display()
1103            ),
1104        )?;
1105
1106        let loaded = load_workspace(Some(&config_path))?;
1107        let resolved =
1108            resolve_project(&loaded, Path::new(&workspace_dir))?.expect("project should resolve");
1109        assert_eq!(resolved.name, "demo");
1110
1111        Ok(())
1112    }
1113
1114    #[test]
1115    fn deletes_project_file_by_name() -> Result<()> {
1116        let tempdir = tempfile::tempdir()?;
1117        let config_path = tempdir.path().join("config.toml");
1118        let project_dir = tempdir.path().join("projects");
1119        fs::create_dir(&project_dir)?;
1120        fs::write(
1121            &config_path,
1122            r#"
1123[templates.default]
1124windows = [{ name = "main" }]
1125"#,
1126        )?;
1127        let project_path = project_dir.join("demo.toml");
1128        fs::write(&project_path, "path = \"/tmp/demo\"\n")?;
1129
1130        let loaded = load_workspace(Some(&config_path))?;
1131        let deleted = super::delete_project_file(&loaded, "demo")?;
1132
1133        assert_eq!(deleted, project_path);
1134        assert!(!deleted.exists());
1135        Ok(())
1136    }
1137
1138    #[test]
1139    fn deletes_invalid_project_file_by_name() -> Result<()> {
1140        let tempdir = tempfile::tempdir()?;
1141        let config_path = tempdir.path().join("config.toml");
1142        let project_dir = tempdir.path().join("projects");
1143        fs::create_dir(&project_dir)?;
1144        fs::write(
1145            &config_path,
1146            r#"
1147[templates.default]
1148windows = [{ name = "main" }]
1149"#,
1150        )?;
1151        let project_path = project_dir.join("broken.toml");
1152        fs::write(&project_path, "not = [valid\n")?;
1153
1154        let loaded = load_workspace(Some(&config_path))?;
1155        assert_eq!(loaded.invalid_projects.len(), 1);
1156        let deleted = super::delete_project_file(&loaded, "broken")?;
1157
1158        assert_eq!(deleted, project_path);
1159        assert!(!deleted.exists());
1160        Ok(())
1161    }
1162
1163    #[test]
1164    fn materializes_project_overrides_on_template() -> Result<()> {
1165        let config: Config = toml::from_str(
1166            r#"
1167[templates.default]
1168startup_window = "main"
1169windows = [{ name = "main" }]
1170"#,
1171        )?;
1172
1173        let project: super::Project = toml::from_str(
1174            r#"
1175path = "/tmp/demo"
1176template = "default"
1177startup_window = "editor"
1178windows = [{ name = "editor", command = "nvim" }]
1179"#,
1180        )?;
1181
1182        let materialized = materialize_project_template(&config, &project)?
1183            .expect("project should materialize a template");
1184        assert_eq!(materialized.startup_window.as_deref(), Some("editor"));
1185        assert_eq!(materialized.windows[0].name, "editor");
1186        Ok(())
1187    }
1188
1189    #[test]
1190    fn loads_from_disk_with_projects() -> Result<()> {
1191        let tempdir = tempfile::tempdir()?;
1192        let path = tempdir.path().join("config.toml");
1193        let project_dir = tempdir.path().join("projects");
1194        fs::create_dir(&project_dir)?;
1195        fs::write(&path, starter_config())?;
1196        fs::write(project_dir.join("example.toml"), starter_project())?;
1197
1198        let loaded = load(Some(&path))?;
1199        assert_eq!(loaded.path, path);
1200        assert!(loaded.projects.contains_key("example"));
1201        Ok(())
1202    }
1203
1204    #[test]
1205    fn loads_projects_without_main_config() -> Result<()> {
1206        let tempdir = tempfile::tempdir()?;
1207        let path = tempdir.path().join("config.toml");
1208        let project_dir = tempdir.path().join("projects");
1209        fs::create_dir(&project_dir)?;
1210        fs::write(
1211            project_dir.join("example.toml"),
1212            r#"
1213path = "/tmp/example"
1214session_name = "example"
1215windows = [{ name = "main", command = "nvim" }]
1216"#,
1217        )?;
1218
1219        let loaded = load_optional(Some(&path))?.expect("workspace should load");
1220        assert!(!loaded.config_exists);
1221        assert!(loaded.projects.contains_key("example"));
1222        Ok(())
1223    }
1224
1225    #[test]
1226    fn init_creates_project_directory_and_starter_project() -> Result<()> {
1227        let tempdir = tempfile::tempdir()?;
1228        let path = tempdir.path().join("config.toml");
1229
1230        let written = super::init(Some(&path))?;
1231        assert_eq!(written, path);
1232        assert!(tempdir.path().join("projects").is_dir());
1233        assert!(
1234            tempdir
1235                .path()
1236                .join("projects")
1237                .join("example.toml")
1238                .exists()
1239        );
1240        Ok(())
1241    }
1242
1243    #[test]
1244    fn uses_xdg_config_home_when_set() -> Result<()> {
1245        let tempdir = tempfile::tempdir()?;
1246        unsafe {
1247            std::env::set_var("XDG_CONFIG_HOME", tempdir.path());
1248        }
1249
1250        let path = super::default_config_path()?;
1251        assert_eq!(path, tempdir.path().join("smux").join("config.toml"));
1252        assert_eq!(
1253            default_projects_dir()?,
1254            tempdir.path().join("smux").join("projects")
1255        );
1256
1257        unsafe {
1258            std::env::remove_var("XDG_CONFIG_HOME");
1259        }
1260
1261        Ok(())
1262    }
1263}