Skip to main content

stout_state/
config.rs

1//! User configuration
2
3use crate::error::Result;
4use crate::paths::Paths;
5use serde::{Deserialize, Serialize};
6
7/// User configuration
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct Config {
10    #[serde(default)]
11    pub index: IndexConfig,
12    #[serde(default)]
13    pub install: InstallConfig,
14    #[serde(default)]
15    pub cache: CacheConfig,
16    #[serde(default)]
17    pub analytics: AnalyticsConfig,
18    #[serde(default)]
19    pub security: SecurityConfig,
20    #[serde(default)]
21    pub sync: SyncConfig,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct IndexConfig {
26    /// Base URL for stout-index repository
27    #[serde(default = "default_base_url")]
28    pub base_url: String,
29    /// Automatically update index
30    #[serde(default = "default_true")]
31    pub auto_update: bool,
32    /// Update interval in seconds
33    #[serde(default = "default_update_interval")]
34    pub update_interval: u64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct InstallConfig {
39    /// Homebrew Cellar path
40    #[serde(default = "default_cellar")]
41    pub cellar: String,
42    /// Homebrew prefix path
43    #[serde(default = "default_prefix")]
44    pub prefix: String,
45    /// Number of parallel downloads
46    #[serde(default = "default_parallel")]
47    pub parallel_downloads: u32,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CacheConfig {
52    /// Maximum cache size
53    #[serde(default = "default_max_size")]
54    pub max_size: String,
55    /// Formula cache TTL in seconds
56    #[serde(default = "default_formula_ttl")]
57    pub formula_ttl: u64,
58    /// Download cache TTL in seconds
59    #[serde(default = "default_download_ttl")]
60    pub download_ttl: u64,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct AnalyticsConfig {
65    /// Enable anonymous usage analytics (opt-in)
66    #[serde(default)]
67    pub enabled: bool,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SecurityConfig {
72    /// Require valid Ed25519 signatures on index updates
73    /// Default: true in release builds, false in debug
74    #[serde(default = "default_require_signature")]
75    pub require_signature: bool,
76    /// Allow unsigned indexes (for development/testing)
77    /// Default: false in release builds, true in debug
78    #[serde(default = "default_allow_unsigned")]
79    pub allow_unsigned: bool,
80    /// Maximum age of signature in seconds before rejecting
81    /// Default: 7 days (604800 seconds)
82    #[serde(default = "default_max_signature_age")]
83    pub max_signature_age: u64,
84    /// Additional trusted public keys (hex-encoded Ed25519 public keys)
85    /// The default stout-index key is always trusted
86    #[serde(default)]
87    pub additional_trusted_keys: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SyncConfig {
92    /// Run full Cellar sync after `stout update`
93    #[serde(default = "default_true")]
94    pub sync_on_update: bool,
95}
96
97impl Default for SyncConfig {
98    fn default() -> Self {
99        Self {
100            sync_on_update: true,
101        }
102    }
103}
104
105// Defaults
106fn default_base_url() -> String {
107    "https://raw.githubusercontent.com/neul-labs/stout-index/main".to_string()
108}
109
110fn default_true() -> bool {
111    true
112}
113
114fn default_update_interval() -> u64 {
115    1800 // 30 minutes
116}
117
118fn default_cellar() -> String {
119    format!("{}/Cellar", default_prefix())
120}
121
122fn default_prefix() -> String {
123    // Use platform-appropriate defaults
124    #[cfg(target_os = "macos")]
125    {
126        #[cfg(target_arch = "aarch64")]
127        return "/opt/homebrew".to_string();
128        #[cfg(not(target_arch = "aarch64"))]
129        return "/usr/local".to_string();
130    }
131
132    #[cfg(target_os = "linux")]
133    {
134        // Linux: use ~/.local/stout for user-level installs
135        if let Some(home) = dirs::home_dir() {
136            return home
137                .join(".local")
138                .join("stout")
139                .to_string_lossy()
140                .to_string();
141        }
142        "/home/linuxbrew/.linuxbrew".to_string()
143    }
144
145    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
146    {
147        "/opt/homebrew".to_string()
148    }
149}
150
151fn default_parallel() -> u32 {
152    4
153}
154
155fn default_max_size() -> String {
156    "2GB".to_string()
157}
158
159fn default_formula_ttl() -> u64 {
160    86400 // 1 day
161}
162
163fn default_download_ttl() -> u64 {
164    604800 // 7 days
165}
166
167fn default_require_signature() -> bool {
168    // TODO: Enable signature requirement once index server implements signing
169    false
170}
171
172fn default_allow_unsigned() -> bool {
173    // TODO: Set to false once index server implements signing
174    true
175}
176
177fn default_max_signature_age() -> u64 {
178    604800 // 7 days
179}
180
181impl Default for IndexConfig {
182    fn default() -> Self {
183        Self {
184            base_url: default_base_url(),
185            auto_update: default_true(),
186            update_interval: default_update_interval(),
187        }
188    }
189}
190
191impl Default for InstallConfig {
192    fn default() -> Self {
193        Self {
194            cellar: default_cellar(),
195            prefix: default_prefix(),
196            parallel_downloads: default_parallel(),
197        }
198    }
199}
200
201impl Default for CacheConfig {
202    fn default() -> Self {
203        Self {
204            max_size: default_max_size(),
205            formula_ttl: default_formula_ttl(),
206            download_ttl: default_download_ttl(),
207        }
208    }
209}
210
211impl Default for SecurityConfig {
212    fn default() -> Self {
213        Self {
214            require_signature: default_require_signature(),
215            allow_unsigned: default_allow_unsigned(),
216            max_signature_age: default_max_signature_age(),
217            additional_trusted_keys: vec![],
218        }
219    }
220}
221
222impl SecurityConfig {
223    /// Convert to stout-index SecurityPolicy
224    pub fn to_security_policy(&self) -> stout_index::SecurityPolicy {
225        stout_index::SecurityPolicy {
226            require_signature: self.require_signature,
227            max_signature_age: self.max_signature_age,
228            additional_keys: self.additional_trusted_keys.clone(),
229            allow_unsigned: self.allow_unsigned,
230        }
231    }
232}
233
234impl Config {
235    /// Load config from file, or return defaults if not found
236    pub fn load(paths: &Paths) -> Result<Self> {
237        let config_path = paths.config_file();
238
239        if config_path.exists() {
240            let contents = std::fs::read_to_string(&config_path)?;
241            let config: Config = toml::from_str(&contents)?;
242            Ok(config)
243        } else {
244            Ok(Self::default())
245        }
246    }
247
248    /// Save config to file
249    pub fn save(&self, paths: &Paths) -> Result<()> {
250        let config_path = paths.config_file();
251
252        if let Some(parent) = config_path.parent() {
253            std::fs::create_dir_all(parent)?;
254        }
255
256        let contents = toml::to_string_pretty(self)?;
257        std::fs::write(&config_path, contents)?;
258        Ok(())
259    }
260}