Skip to main content

actr_config/user_config/
schema.rs

1//! Shared CLI/user configuration schema.
2
3use crate::error::{ConfigError, Result};
4use serde::{Deserialize, Serialize};
5
6/// User configuration file schema.
7///
8/// Represents the structure of both `~/.actr/config.toml` and `.actr/config.toml`.
9/// All fields are optional to allow partial overrides.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11#[serde(deny_unknown_fields)]
12pub struct CliConfig {
13    /// Config file format version (for future migration)
14    pub version: Option<u32>,
15
16    /// Manufacturer identity settings (MFR)
17    #[serde(default)]
18    pub mfr: MfrConfig,
19
20    /// Code generation settings
21    #[serde(default)]
22    pub codegen: CodegenConfig,
23
24    /// Package installation settings
25    #[serde(default)]
26    pub install: InstallConfig,
27
28    /// Cache settings
29    #[serde(default)]
30    pub cache: CacheConfig,
31
32    /// UI/output settings
33    #[serde(default)]
34    pub ui: UiConfig,
35
36    /// Network settings for CLI service discovery and connectivity checks
37    #[serde(default)]
38    pub network: NetworkConfig,
39
40    /// Storage settings
41    #[serde(default)]
42    pub storage: StorageConfig,
43}
44
45impl CliConfig {
46    /// Validate configuration values.
47    pub fn validate(&self) -> Result<()> {
48        if let Some(v) = self.version {
49            if v != 1 {
50                return Err(ConfigError::ValidationError(format!(
51                    "Unsupported config version: {}. Supported version is 1",
52                    v
53                )));
54            }
55        }
56
57        if let Some(ref manufacturer) = self.mfr.manufacturer {
58            if manufacturer.trim().is_empty() {
59                return Err(ConfigError::ValidationError(
60                    "mfr.manufacturer cannot be empty".to_string(),
61                ));
62            }
63        }
64
65        if let Some(ref keychain) = self.mfr.keychain {
66            if keychain.trim().is_empty() {
67                return Err(ConfigError::ValidationError(
68                    "mfr.keychain cannot be empty string (omit the field instead)".to_string(),
69                ));
70            }
71        }
72
73        if let Some(ref language) = self.codegen.language {
74            let valid_languages = ["rust", "typescript", "swift", "kotlin", "python", "web"];
75            if !valid_languages.contains(&language.as_str()) {
76                return Err(ConfigError::ValidationError(format!(
77                    "codegen.language '{}' is invalid. Valid values: {}",
78                    language,
79                    valid_languages.join(", ")
80                )));
81            }
82        }
83
84        if let Some(ref format) = self.ui.format {
85            let valid_formats = ["toml", "json", "yaml"];
86            if !valid_formats.contains(&format.as_str()) {
87                return Err(ConfigError::ValidationError(format!(
88                    "ui.format '{}' is invalid. Valid values: {}",
89                    format,
90                    valid_formats.join(", ")
91                )));
92            }
93        }
94
95        if let Some(ref color) = self.ui.color {
96            let valid_colors = ["auto", "always", "never"];
97            if !valid_colors.contains(&color.as_str()) {
98                return Err(ConfigError::ValidationError(format!(
99                    "ui.color '{}' is invalid. Valid values: {}",
100                    color,
101                    valid_colors.join(", ")
102                )));
103            }
104        }
105
106        if let Some(ref url) = self.network.signaling_url {
107            if url.trim().is_empty() {
108                return Err(ConfigError::ValidationError(
109                    "network.signaling_url cannot be empty".to_string(),
110                ));
111            }
112            if !url.starts_with("ws://") && !url.starts_with("wss://") {
113                return Err(ConfigError::ValidationError(format!(
114                    "network.signaling_url '{}' must start with ws:// or wss://",
115                    url
116                )));
117            }
118        }
119
120        if let Some(ref url) = self.network.ais_endpoint {
121            if url.trim().is_empty() {
122                return Err(ConfigError::ValidationError(
123                    "network.ais_endpoint cannot be empty".to_string(),
124                ));
125            }
126            if !url.starts_with("http://") && !url.starts_with("https://") {
127                return Err(ConfigError::ValidationError(format!(
128                    "network.ais_endpoint '{}' must start with http:// or https://",
129                    url
130                )));
131            }
132        }
133
134        if let Some(realm_id) = self.network.realm_id {
135            if realm_id == 0 {
136                return Err(ConfigError::ValidationError(
137                    "network.realm_id must be a positive integer".to_string(),
138                ));
139            }
140        }
141
142        if let Some(ref secret) = self.network.realm_secret {
143            if secret.is_empty() {
144                return Err(ConfigError::ValidationError(
145                    "network.realm_secret cannot be empty string (omit the field instead)"
146                        .to_string(),
147                ));
148            }
149        }
150
151        Ok(())
152    }
153}
154
155/// Manufacturer identity settings (MFR).
156#[derive(Debug, Clone, Serialize, Deserialize, Default)]
157#[serde(deny_unknown_fields)]
158pub struct MfrConfig {
159    /// Default manufacturer for generated actor types (e.g., "acme")
160    pub manufacturer: Option<String>,
161
162    /// Path to the signing keychain JSON file (supports `~` expansion)
163    pub keychain: Option<String>,
164}
165
166/// Code generation settings.
167#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168#[serde(deny_unknown_fields)]
169pub struct CodegenConfig {
170    /// Default target language for code generation
171    pub language: Option<String>,
172
173    /// Default output directory for generated code
174    pub output: Option<String>,
175
176    /// Clean output directory before generating code
177    pub clean_before_generate: Option<bool>,
178}
179
180/// Package installation settings.
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182#[serde(deny_unknown_fields)]
183pub struct InstallConfig {}
184
185/// Cache settings.
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187#[serde(deny_unknown_fields)]
188pub struct CacheConfig {
189    /// Cache directory path (supports `~` expansion)
190    pub dir: Option<String>,
191
192    /// Automatically generate/update lock file after installation
193    pub auto_lock: Option<bool>,
194
195    /// Prefer cached packages over re-downloading
196    pub prefer_cache: Option<bool>,
197}
198
199/// UI/output settings.
200#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201#[serde(deny_unknown_fields)]
202pub struct UiConfig {
203    /// Output format for structured commands: "toml", "json", "yaml"
204    pub format: Option<String>,
205
206    /// Verbose output
207    pub verbose: Option<bool>,
208
209    /// Color output: "auto", "always", "never"
210    pub color: Option<String>,
211
212    /// Non-interactive mode (skip prompts)
213    pub non_interactive: Option<bool>,
214}
215
216/// Network settings used by CLI/user-facing connectivity operations.
217#[derive(Debug, Clone, Serialize, Deserialize, Default)]
218#[serde(deny_unknown_fields)]
219pub struct NetworkConfig {
220    /// Signaling server URL for CLI discovery
221    pub signaling_url: Option<String>,
222
223    /// AIS endpoint for CLI discovery
224    pub ais_endpoint: Option<String>,
225
226    /// Realm ID for CLI temporary actor registration
227    pub realm_id: Option<u32>,
228
229    /// Realm secret for authentication (optional)
230    pub realm_secret: Option<String>,
231}
232
233/// Storage settings.
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235#[serde(deny_unknown_fields)]
236pub struct StorageConfig {
237    /// Global Hyper data directory path (supports `~` expansion).
238    pub hyper_data_dir: Option<String>,
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn validate_accepts_storage_hyper_dir() {
247        let config = CliConfig {
248            storage: StorageConfig {
249                hyper_data_dir: Some("~/.actr/hyper".to_string()),
250            },
251            ..Default::default()
252        };
253
254        assert!(config.validate().is_ok());
255    }
256
257    #[test]
258    fn validate_rejects_invalid_version() {
259        let config = CliConfig {
260            version: Some(2),
261            ..Default::default()
262        };
263
264        assert!(config.validate().is_err());
265    }
266}