Skip to main content

vibe_workspace/workspace/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use tokio::fs;
6
7use crate::worktree::config::{
8    WorktreeCleanupConfig, WorktreeConfig, WorktreeMergeDetectionConfig, WorktreeMode,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct WorkspaceConfig {
13    pub workspace: WorkspaceInfo,
14    pub repositories: Vec<Repository>,
15    pub groups: Vec<RepositoryGroup>,
16    pub apps: AppIntegrations,
17    #[serde(default)]
18    pub preferences: Option<Preferences>,
19    #[serde(default)]
20    pub claude_agents: Option<ClaudeAgentsIntegration>,
21    #[serde(default)]
22    pub worktree: WorktreeConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WorkspaceInfo {
27    pub name: String,
28    pub root: PathBuf,
29    pub auto_discover: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Repository {
34    pub name: String,
35    pub path: PathBuf,
36    pub url: Option<String>,
37    pub branch: Option<String>,
38    pub apps: HashMap<String, AppConfig>,
39    #[serde(default)]
40    pub worktree_config: Option<RepositoryWorktreeConfig>,
41}
42
43/// Repository-specific worktree configuration overrides
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RepositoryWorktreeConfig {
46    /// Override worktree storage mode for this repository
47    pub mode: Option<WorktreeMode>,
48
49    /// Override global base directory for this repository
50    pub base_dir: Option<PathBuf>,
51
52    /// Override global prefix for this repository
53    pub prefix: Option<String>,
54
55    /// Repository-specific cleanup settings
56    pub cleanup: Option<WorktreeCleanupConfig>,
57
58    /// Repository-specific merge detection settings
59    pub merge_detection: Option<WorktreeMergeDetectionConfig>,
60
61    /// Disable worktree management for this repository
62    pub disabled: Option<bool>,
63}
64
65impl RepositoryWorktreeConfig {
66    /// Merge repository-specific config with global config
67    pub fn merge_with_global(&self, global: &WorktreeConfig) -> WorktreeConfig {
68        WorktreeConfig {
69            mode: self.mode.unwrap_or(global.mode),
70            base_dir: self
71                .base_dir
72                .clone()
73                .unwrap_or_else(|| global.base_dir.clone()),
74            prefix: self.prefix.clone().unwrap_or_else(|| global.prefix.clone()),
75            auto_gitignore: global.auto_gitignore, // Always use global setting
76            default_editor: global.default_editor.clone(), // Always use global setting
77            cleanup: self
78                .cleanup
79                .clone()
80                .unwrap_or_else(|| global.cleanup.clone()),
81            merge_detection: self
82                .merge_detection
83                .clone()
84                .unwrap_or_else(|| global.merge_detection.clone()),
85            status: global.status.clone(), // Always use global status settings
86        }
87    }
88
89    /// Check if worktree management is enabled for this repository
90    pub fn is_enabled(&self) -> bool {
91        !self.disabled.unwrap_or(false)
92    }
93}
94
95impl Default for RepositoryWorktreeConfig {
96    fn default() -> Self {
97        Self {
98            mode: None,
99            base_dir: None,
100            prefix: None,
101            cleanup: None,
102            merge_detection: None,
103            disabled: None,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(untagged)]
110pub enum AppConfig {
111    Enabled(bool),
112    WithTemplate {
113        template: String,
114    },
115    WithConfig {
116        template: String,
117        #[serde(default)]
118        config: serde_json::Value,
119    },
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RepositoryGroup {
124    pub name: String,
125    pub repos: Vec<String>,
126    pub apps: HashMap<String, AppIntegration>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct AppIntegrations {
131    pub github: Option<GitHubIntegration>,
132    pub warp: Option<WarpIntegration>,
133    pub iterm2: Option<ITerm2Integration>,
134    pub vscode: Option<VSCodeIntegration>,
135    pub wezterm: Option<WezTermIntegration>,
136    pub cursor: Option<CursorIntegration>,
137    pub windsurf: Option<WindsurfIntegration>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GitHubIntegration {
142    pub enabled: bool,
143    pub token_source: String, // "gh", "env", or "file"
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct WarpIntegration {
148    pub enabled: bool,
149    pub config_dir: PathBuf,
150    #[serde(default = "default_warp_template_dir")]
151    pub template_dir: PathBuf,
152    #[serde(default = "default_template_name")]
153    pub default_template: String,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ITerm2Integration {
158    pub enabled: bool,
159    pub config_dir: PathBuf,
160    #[serde(default = "default_iterm2_template_dir")]
161    pub template_dir: PathBuf,
162    #[serde(default = "default_template_name")]
163    pub default_template: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct WezTermIntegration {
168    pub enabled: bool,
169    pub config_dir: PathBuf,
170    #[serde(default = "default_wezterm_template_dir")]
171    pub template_dir: PathBuf,
172    #[serde(default = "default_template_name")]
173    pub default_template: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct VSCodeIntegration {
178    pub enabled: bool,
179    pub workspace_dir: PathBuf,
180    #[serde(default = "default_vscode_template_dir")]
181    pub template_dir: PathBuf,
182    #[serde(default = "default_template_name")]
183    pub default_template: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CursorIntegration {
188    pub enabled: bool,
189    pub workspace_dir: PathBuf,
190    #[serde(default = "default_cursor_template_dir")]
191    pub template_dir: PathBuf,
192    #[serde(default = "default_template_name")]
193    pub default_template: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct WindsurfIntegration {
198    pub enabled: bool,
199    pub workspace_dir: PathBuf,
200    #[serde(default = "default_windsurf_template_dir")]
201    pub template_dir: PathBuf,
202    #[serde(default = "default_template_name")]
203    pub default_template: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ClaudeAgentsIntegration {
208    pub enabled: bool,
209    #[serde(default = "default_claude_agents_source_path")]
210    pub source_path: PathBuf,
211    #[serde(default = "default_claude_agents_target_path")]
212    pub target_path: PathBuf,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216pub struct Preferences {
217    #[serde(default)]
218    pub page_sizes: PageSizes,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct PageSizes {
223    #[serde(default = "default_main_menu_page_size")]
224    pub main_menu: usize,
225    #[serde(default = "default_repository_list_page_size")]
226    pub repository_list: usize,
227    #[serde(default = "default_quick_launch_page_size")]
228    pub quick_launch: usize,
229    #[serde(default = "default_app_selection_page_size")]
230    pub app_selection: usize,
231    #[serde(default = "default_git_search_results_page_size")]
232    pub git_search_results: usize,
233    #[serde(default = "default_management_menus_page_size")]
234    pub management_menus: usize,
235    #[serde(default = "default_app_installer_page_size")]
236    pub app_installer: usize,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(untagged)]
241pub enum AppIntegration {
242    Simple(bool),
243    Warp { commands: Vec<String> },
244    VSCode { extensions: Vec<String> },
245    ITerm2 { profile: String },
246}
247
248impl Default for WorkspaceConfig {
249    fn default() -> Self {
250        let vibe_dir = super::constants::get_config_dir();
251
252        Self {
253            workspace: WorkspaceInfo {
254                name: "default".to_string(),
255                root: PathBuf::from("."),
256                auto_discover: true,
257            },
258            repositories: Vec::new(),
259            groups: Vec::new(),
260            apps: AppIntegrations {
261                github: Some(GitHubIntegration {
262                    enabled: true,
263                    token_source: "gh".to_string(),
264                }),
265                warp: Some(WarpIntegration {
266                    enabled: true,
267                    config_dir: dirs::home_dir()
268                        .unwrap_or_default()
269                        .join(".warp")
270                        .join("launch_configurations"),
271                    template_dir: vibe_dir.join("templates").join("warp"),
272                    default_template: "default".to_string(),
273                }),
274                iterm2: Some(ITerm2Integration {
275                    enabled: true,
276                    config_dir: dirs::home_dir()
277                        .unwrap_or_default()
278                        .join("Library")
279                        .join("Application Support")
280                        .join("iTerm2")
281                        .join("DynamicProfiles"),
282                    template_dir: vibe_dir.join("templates").join("iterm2"),
283                    default_template: "default".to_string(),
284                }),
285                wezterm: Some(WezTermIntegration {
286                    enabled: true,
287                    config_dir: dirs::config_dir()
288                        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
289                        .join("wezterm"),
290                    template_dir: vibe_dir.join("templates").join("wezterm"),
291                    default_template: "default".to_string(),
292                }),
293                vscode: Some(VSCodeIntegration {
294                    enabled: true,
295                    workspace_dir: dirs::home_dir()
296                        .unwrap_or_default()
297                        .join(".vscode")
298                        .join("workspaces"),
299                    template_dir: vibe_dir.join("templates").join("vscode"),
300                    default_template: "default".to_string(),
301                }),
302                cursor: Some(CursorIntegration {
303                    enabled: true,
304                    workspace_dir: dirs::home_dir()
305                        .unwrap_or_default()
306                        .join(".cursor")
307                        .join("workspaces"),
308                    template_dir: vibe_dir.join("templates").join("cursor"),
309                    default_template: "default".to_string(),
310                }),
311                windsurf: Some(WindsurfIntegration {
312                    enabled: true,
313                    workspace_dir: dirs::home_dir()
314                        .unwrap_or_default()
315                        .join(".windsurf")
316                        .join("workspaces"),
317                    template_dir: vibe_dir.join("templates").join("windsurf"),
318                    default_template: "default".to_string(),
319                }),
320            },
321            preferences: Some(Preferences::default()),
322            claude_agents: Some(ClaudeAgentsIntegration {
323                enabled: true,
324                source_path: PathBuf::from(".").join("wshobson").join("agents"),
325                target_path: dirs::home_dir()
326                    .unwrap_or_default()
327                    .join(".claude")
328                    .join("agents"),
329            }),
330            worktree: WorktreeConfig::default(),
331        }
332    }
333}
334
335impl WorkspaceConfig {
336    pub async fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
337        let path = path.as_ref();
338
339        if !path.exists() {
340            return Ok(Self::default());
341        }
342
343        let contents = fs::read_to_string(path)
344            .await
345            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
346
347        let mut config: Self = serde_yaml::from_str(&contents)
348            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
349
350        // Ensure all app integrations are initialized
351        config.ensure_app_integrations_initialized().await?;
352
353        Ok(config)
354    }
355
356    pub async fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
357        let path = path.as_ref();
358
359        // Create parent directory if it doesn't exist
360        if let Some(parent) = path.parent() {
361            fs::create_dir_all(parent).await.with_context(|| {
362                format!("Failed to create config directory: {}", parent.display())
363            })?;
364        }
365
366        let yaml = serde_yaml::to_string(self).context("Failed to serialize config to YAML")?;
367
368        fs::write(path, yaml)
369            .await
370            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
371
372        Ok(())
373    }
374
375    pub fn get_repository(&self, name: &str) -> Option<&Repository> {
376        self.repositories.iter().find(|repo| repo.name == name)
377    }
378
379    /// Get a repository by flexible name lookup (supports owner/repo format)
380    pub fn get_repository_flexible(&self, name: &str) -> Option<&Repository> {
381        // First try exact match
382        if let Some(repo) = self.get_repository(name) {
383            return Some(repo);
384        }
385
386        // Try case-insensitive match
387        let lower_name = name.to_lowercase();
388        if let Some(repo) = self
389            .repositories
390            .iter()
391            .find(|repo| repo.name.to_lowercase() == lower_name)
392        {
393            return Some(repo);
394        }
395
396        // Try extracting repo name from owner/repo format
397        if let Some((_owner, repo_name)) = name.split_once('/') {
398            // Try exact match on repo name
399            if let Some(repo) = self.get_repository(repo_name) {
400                return Some(repo);
401            }
402
403            // Try case-insensitive match on repo name
404            let lower_repo_name = repo_name.to_lowercase();
405            if let Some(repo) = self
406                .repositories
407                .iter()
408                .find(|repo| repo.name.to_lowercase() == lower_repo_name)
409            {
410                return Some(repo);
411            }
412        }
413
414        // Try to match against URL if present
415        let lower_search = name.to_lowercase();
416        self.repositories.iter().find(|repo| {
417            if let Some(url) = &repo.url {
418                let lower_url = url.to_lowercase();
419                // Check if URL contains the search term (handles owner/repo in URLs)
420                lower_url.contains(&lower_search) ||
421                // Check if the last part of the URL path matches
422                lower_url.split('/').next_back()
423                    .map(|last| last.trim_end_matches(".git") == lower_search)
424                    .unwrap_or(false)
425            } else {
426                false
427            }
428        })
429    }
430
431    pub fn get_repositories_in_group(&self, group_name: &str) -> Vec<&Repository> {
432        if let Some(group) = self.groups.iter().find(|g| g.name == group_name) {
433            group
434                .repos
435                .iter()
436                .filter_map(|repo_name| self.get_repository(repo_name))
437                .collect()
438        } else {
439            Vec::new()
440        }
441    }
442
443    pub fn add_repository(&mut self, repo: Repository) {
444        // Remove existing repository with same name if present
445        self.repositories.retain(|r| r.name != repo.name);
446        self.repositories.push(repo);
447    }
448
449    pub fn add_group(&mut self, group: RepositoryGroup) {
450        // Remove existing group with same name if present
451        self.groups.retain(|g| g.name != group.name);
452        self.groups.push(group);
453    }
454
455    /// Ensure all app integrations are properly initialized
456    /// This method handles migration from older configurations that may not have all apps configured
457    pub async fn ensure_app_integrations_initialized(&mut self) -> Result<()> {
458        let vibe_dir = super::constants::get_config_dir();
459
460        // Ensure WezTerm integration is initialized
461        if self.apps.wezterm.is_none() {
462            self.apps.wezterm = Some(WezTermIntegration {
463                enabled: true,
464                config_dir: dirs::config_dir()
465                    .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
466                    .join("wezterm"),
467                template_dir: vibe_dir.join("templates").join("wezterm"),
468                default_template: "default".to_string(),
469            });
470        }
471
472        // Initialize other app integrations if they're missing
473        if self.apps.warp.is_none() {
474            self.apps.warp = Some(WarpIntegration {
475                enabled: true,
476                config_dir: dirs::home_dir()
477                    .unwrap_or_default()
478                    .join(".warp")
479                    .join("launch_configurations"),
480                template_dir: vibe_dir.join("templates").join("warp"),
481                default_template: "default".to_string(),
482            });
483        }
484
485        if self.apps.iterm2.is_none() {
486            self.apps.iterm2 = Some(ITerm2Integration {
487                enabled: true,
488                config_dir: dirs::home_dir()
489                    .unwrap_or_default()
490                    .join("Library")
491                    .join("Application Support")
492                    .join("iTerm2")
493                    .join("DynamicProfiles"),
494                template_dir: vibe_dir.join("templates").join("iterm2"),
495                default_template: "default".to_string(),
496            });
497        }
498
499        if self.apps.vscode.is_none() {
500            self.apps.vscode = Some(VSCodeIntegration {
501                enabled: true,
502                workspace_dir: dirs::home_dir()
503                    .unwrap_or_default()
504                    .join(".vscode")
505                    .join("workspaces"),
506                template_dir: vibe_dir.join("templates").join("vscode"),
507                default_template: "default".to_string(),
508            });
509        }
510
511        if self.apps.cursor.is_none() {
512            self.apps.cursor = Some(CursorIntegration {
513                enabled: true,
514                workspace_dir: dirs::home_dir()
515                    .unwrap_or_default()
516                    .join(".cursor")
517                    .join("workspaces"),
518                template_dir: vibe_dir.join("templates").join("cursor"),
519                default_template: "default".to_string(),
520            });
521        }
522
523        if self.apps.windsurf.is_none() {
524            self.apps.windsurf = Some(WindsurfIntegration {
525                enabled: true,
526                workspace_dir: dirs::home_dir()
527                    .unwrap_or_default()
528                    .join(".windsurf")
529                    .join("workspaces"),
530                template_dir: vibe_dir.join("templates").join("windsurf"),
531                default_template: "default".to_string(),
532            });
533        }
534
535        // Initialize claude_agents integration if missing
536        if self.claude_agents.is_none() {
537            self.claude_agents = Some(ClaudeAgentsIntegration {
538                enabled: true,
539                source_path: self.workspace.root.join("wshobson").join("agents"),
540                target_path: dirs::home_dir()
541                    .unwrap_or_default()
542                    .join(".claude")
543                    .join("agents"),
544            });
545        }
546
547        Ok(())
548    }
549
550    /// Get effective worktree configuration for a specific repository
551    pub fn get_worktree_config_for_repo(&self, repo_name: &str) -> WorktreeConfig {
552        if let Some(repo) = self.repositories.iter().find(|r| r.name == repo_name) {
553            if let Some(repo_config) = &repo.worktree_config {
554                if repo_config.is_enabled() {
555                    return repo_config.merge_with_global(&self.worktree);
556                }
557            }
558        }
559
560        // Return global config if no repository-specific overrides
561        self.worktree.clone()
562    }
563
564    /// Check if worktree management is enabled for a repository
565    pub fn is_worktree_enabled_for_repo(&self, repo_name: &str) -> bool {
566        if let Some(repo) = self.repositories.iter().find(|r| r.name == repo_name) {
567            if let Some(repo_config) = &repo.worktree_config {
568                return repo_config.is_enabled();
569            }
570        }
571
572        true // Enabled by default
573    }
574}
575
576impl Repository {
577    pub fn new<S: Into<String>, P: Into<PathBuf>>(name: S, path: P) -> Self {
578        Self {
579            name: name.into(),
580            path: path.into(),
581            url: None,
582            branch: None,
583            apps: HashMap::new(),
584            worktree_config: None,
585        }
586    }
587
588    pub fn with_url<S: Into<String>>(mut self, url: S) -> Self {
589        self.url = Some(url.into());
590        self
591    }
592
593    pub fn with_branch<S: Into<String>>(mut self, branch: S) -> Self {
594        self.branch = Some(branch.into());
595        self
596    }
597
598    pub fn enable_app<S: Into<String>>(mut self, app: S) -> Self {
599        self.apps.insert(app.into(), AppConfig::Enabled(true));
600        self
601    }
602
603    pub fn enable_app_with_template<S: Into<String>, T: Into<String>>(
604        mut self,
605        app: S,
606        template: T,
607    ) -> Self {
608        self.apps.insert(
609            app.into(),
610            AppConfig::WithTemplate {
611                template: template.into(),
612            },
613        );
614        self
615    }
616
617    pub fn is_app_enabled(&self, app: &str) -> bool {
618        match self.apps.get(app) {
619            Some(AppConfig::Enabled(enabled)) => *enabled,
620            Some(AppConfig::WithTemplate { .. }) => true,
621            Some(AppConfig::WithConfig { .. }) => true,
622            None => false,
623        }
624    }
625
626    pub fn get_app_template(&self, app: &str) -> Option<&str> {
627        match self.apps.get(app) {
628            Some(AppConfig::WithTemplate { template }) => Some(template),
629            Some(AppConfig::WithConfig { template, .. }) => Some(template),
630            _ => None,
631        }
632    }
633}
634
635impl AppConfig {
636    pub fn is_enabled(&self) -> bool {
637        match self {
638            AppConfig::Enabled(enabled) => *enabled,
639            AppConfig::WithTemplate { .. } => true,
640            AppConfig::WithConfig { .. } => true,
641        }
642    }
643}
644
645// Default functions for serde
646fn default_template_name() -> String {
647    "default".to_string()
648}
649
650fn default_warp_template_dir() -> PathBuf {
651    dirs::home_dir()
652        .unwrap_or_default()
653        .join(super::constants::CONFIG_DIR_PATH)
654        .join("templates")
655        .join("warp")
656}
657
658fn default_iterm2_template_dir() -> PathBuf {
659    dirs::home_dir()
660        .unwrap_or_default()
661        .join(super::constants::CONFIG_DIR_PATH)
662        .join("templates")
663        .join("iterm2")
664}
665
666fn default_wezterm_template_dir() -> PathBuf {
667    dirs::home_dir()
668        .unwrap_or_default()
669        .join(super::constants::CONFIG_DIR_PATH)
670        .join("templates")
671        .join("wezterm")
672}
673
674fn default_vscode_template_dir() -> PathBuf {
675    dirs::home_dir()
676        .unwrap_or_default()
677        .join(super::constants::CONFIG_DIR_PATH)
678        .join("templates")
679        .join("vscode")
680}
681
682fn default_cursor_template_dir() -> PathBuf {
683    dirs::home_dir()
684        .unwrap_or_default()
685        .join(super::constants::CONFIG_DIR_PATH)
686        .join("templates")
687        .join("cursor")
688}
689
690fn default_windsurf_template_dir() -> PathBuf {
691    dirs::home_dir()
692        .unwrap_or_default()
693        .join(super::constants::CONFIG_DIR_PATH)
694        .join("templates")
695        .join("windsurf")
696}
697
698fn default_claude_agents_source_path() -> PathBuf {
699    PathBuf::from(".").join("wshobson").join("agents")
700}
701
702fn default_claude_agents_target_path() -> PathBuf {
703    dirs::home_dir()
704        .unwrap_or_default()
705        .join(".claude")
706        .join("agents")
707}
708
709// Page size defaults
710fn default_main_menu_page_size() -> usize {
711    15
712}
713
714fn default_repository_list_page_size() -> usize {
715    15
716}
717
718fn default_quick_launch_page_size() -> usize {
719    9
720}
721
722fn default_app_selection_page_size() -> usize {
723    10
724}
725
726fn default_git_search_results_page_size() -> usize {
727    15
728}
729
730fn default_management_menus_page_size() -> usize {
731    10
732}
733
734fn default_app_installer_page_size() -> usize {
735    15
736}
737
738impl Default for PageSizes {
739    fn default() -> Self {
740        Self {
741            main_menu: default_main_menu_page_size(),
742            repository_list: default_repository_list_page_size(),
743            quick_launch: default_quick_launch_page_size(),
744            app_selection: default_app_selection_page_size(),
745            git_search_results: default_git_search_results_page_size(),
746            management_menus: default_management_menus_page_size(),
747            app_installer: default_app_installer_page_size(),
748        }
749    }
750}
751
752impl PageSizes {
753    /// Validate page size values and return errors for invalid ranges
754    pub fn validate(&self) -> Result<()> {
755        if self.quick_launch == 0 || self.quick_launch > 9 {
756            anyhow::bail!("quick_launch page size must be between 1 and 9 (limited by number key shortcuts), got {}", self.quick_launch);
757        }
758
759        let sizes = [
760            ("main_menu", self.main_menu),
761            ("repository_list", self.repository_list),
762            ("app_selection", self.app_selection),
763            ("git_search_results", self.git_search_results),
764            ("management_menus", self.management_menus),
765            ("app_installer", self.app_installer),
766        ];
767
768        for (name, size) in &sizes {
769            if *size == 0 || *size > 15 {
770                anyhow::bail!("{} page size must be between 1 and 15, got {}", name, size);
771            }
772        }
773
774        Ok(())
775    }
776}