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}