Skip to main content

subcog/config/
org.rs

1//! Organization configuration for shared memory graphs.
2//!
3//! Provides configuration for org-scoped memory storage with support for
4//! both `SQLite` (shared file) and PostgreSQL backends.
5//!
6//! # Example TOML
7//!
8//! ```toml
9//! [org]
10//! name = "acme-corp"
11//! backend = "sqlite"
12//! sqlite_path = "/shared/org/acme-corp/index.db"
13//!
14//! # OR for PostgreSQL:
15//! # backend = "postgresql"
16//! # postgres_url = "postgresql://user:pass@host:5432/subcog_org"
17//! # postgres_max_connections = 10
18//! # postgres_timeout_secs = 30
19//! ```
20
21use serde::Deserialize;
22use std::path::PathBuf;
23
24use super::expand_config_path;
25
26/// Runtime organization configuration.
27///
28/// Controls org-scoped memory storage for team collaboration.
29#[derive(Debug, Clone)]
30pub struct OrgConfig {
31    /// Organization name/identifier (e.g., "acme-corp").
32    ///
33    /// Used in URN construction: `subcog://org/{name}/namespace/id`
34    pub name: Option<String>,
35
36    /// Backend configuration for org-scoped storage.
37    pub backend: OrgBackendConfig,
38
39    /// Whether org scope is enabled (derived from feature flag + config).
40    pub enabled: bool,
41}
42
43impl Default for OrgConfig {
44    fn default() -> Self {
45        Self {
46            name: None,
47            backend: OrgBackendConfig::None,
48            enabled: false,
49        }
50    }
51}
52
53impl OrgConfig {
54    /// Creates org config from a config file section.
55    ///
56    /// # Arguments
57    ///
58    /// * `file` - The parsed config file org section
59    /// * `org_scope_enabled` - Whether the `org_scope` feature flag is enabled
60    #[must_use]
61    pub fn from_config_file(file: &ConfigFileOrg, org_scope_enabled: bool) -> Self {
62        let backend = OrgBackendConfig::from_config_file(file);
63        let has_backend = !matches!(backend, OrgBackendConfig::None);
64
65        Self {
66            name: file.name.clone(),
67            backend,
68            enabled: org_scope_enabled && has_backend,
69        }
70    }
71
72    /// Returns true if org scope is properly configured and enabled.
73    #[must_use]
74    pub const fn is_available(&self) -> bool {
75        self.enabled && !matches!(self.backend, OrgBackendConfig::None)
76    }
77
78    /// Returns the org name or a default value.
79    #[must_use]
80    pub fn name_or_default(&self) -> &str {
81        self.name.as_deref().unwrap_or("default")
82    }
83}
84
85/// Backend configuration for org-scoped storage.
86///
87/// Supports multiple storage backends for different deployment scenarios:
88/// - `SqliteShared`: Simple shared file for small teams (NFS, Dropbox, S3)
89/// - `Postgresql`: Production database for larger teams with concurrent access
90#[derive(Debug, Clone, Default)]
91pub enum OrgBackendConfig {
92    /// Shared `SQLite` file backend.
93    ///
94    /// Suitable for small teams sharing a network filesystem.
95    /// Requires proper file permissions for concurrent access.
96    SqliteShared {
97        /// Path to shared `SQLite` file.
98        ///
99        /// Must be writable by all team members.
100        /// Example: `/shared/org/acme-corp/index.db`
101        path: PathBuf,
102    },
103
104    /// PostgreSQL backend.
105    ///
106    /// Recommended for production use with multiple concurrent users.
107    /// Provides proper transaction isolation and connection pooling.
108    Postgresql {
109        /// PostgreSQL connection URL.
110        ///
111        /// Format: `postgresql://user:pass@host:port/database`
112        /// Supports environment variable expansion: `${SUBCOG_ORG_DB_URL}`
113        connection_url: String,
114
115        /// Maximum connections in the pool.
116        ///
117        /// Default: 10
118        max_connections: u32,
119
120        /// Connection timeout in seconds.
121        ///
122        /// Default: 30
123        timeout_secs: u64,
124    },
125
126    /// No backend configured (org scope disabled).
127    #[default]
128    None,
129}
130
131impl OrgBackendConfig {
132    /// Creates backend config from a config file section.
133    #[must_use]
134    pub fn from_config_file(file: &ConfigFileOrg) -> Self {
135        match file.backend.as_deref() {
136            Some("sqlite" | "sqlite3") => file.sqlite_path.as_ref().map_or_else(
137                || {
138                    tracing::warn!("org.backend=sqlite but org.sqlite_path not set");
139                    Self::None
140                },
141                |path| Self::SqliteShared {
142                    path: PathBuf::from(expand_config_path(path)),
143                },
144            ),
145            Some("postgresql" | "postgres" | "pg") => file.postgres_url.as_ref().map_or_else(
146                || {
147                    tracing::warn!("org.backend=postgresql but org.postgres_url not set");
148                    Self::None
149                },
150                |url| Self::Postgresql {
151                    connection_url: expand_config_path(url),
152                    max_connections: file.postgres_max_connections.unwrap_or(10),
153                    timeout_secs: file.postgres_timeout_secs.unwrap_or(30),
154                },
155            ),
156            Some("none") | None => Self::None,
157            Some(unknown) => {
158                tracing::warn!(
159                    backend = unknown,
160                    "Unknown org backend type, disabling org scope"
161                );
162                Self::None
163            },
164        }
165    }
166
167    /// Returns true if this is a configured backend (not None).
168    #[must_use]
169    pub const fn is_configured(&self) -> bool {
170        !matches!(self, Self::None)
171    }
172
173    /// Returns a display string for the backend type.
174    #[must_use]
175    pub const fn backend_type(&self) -> &'static str {
176        match self {
177            Self::SqliteShared { .. } => "sqlite",
178            Self::Postgresql { .. } => "postgresql",
179            Self::None => "none",
180        }
181    }
182}
183
184/// Organization configuration from config file.
185///
186/// Parsed from the `[org]` section in `subcog.toml`.
187#[derive(Debug, Clone, Deserialize, Default)]
188pub struct ConfigFileOrg {
189    /// Organization name/identifier.
190    ///
191    /// Used for URN construction and display.
192    pub name: Option<String>,
193
194    /// Backend type: "sqlite", "postgresql", or "none".
195    ///
196    /// Default: "none" (org scope disabled)
197    pub backend: Option<String>,
198
199    /// Path to shared `SQLite` file (when backend = "sqlite").
200    ///
201    /// Supports `~` expansion and environment variables: `${VAR}`
202    pub sqlite_path: Option<String>,
203
204    /// PostgreSQL connection URL (when backend = "postgresql").
205    ///
206    /// Format: `postgresql://user:pass@host:port/database`
207    /// Supports environment variable expansion.
208    pub postgres_url: Option<String>,
209
210    /// PostgreSQL maximum connections (default: 10).
211    pub postgres_max_connections: Option<u32>,
212
213    /// PostgreSQL connection timeout in seconds (default: 30).
214    pub postgres_timeout_secs: Option<u64>,
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_org_config_default() {
223        let config = OrgConfig::default();
224        assert!(config.name.is_none());
225        assert!(!config.enabled);
226        assert!(!config.is_available());
227    }
228
229    #[test]
230    fn test_org_backend_sqlite() {
231        let file = ConfigFileOrg {
232            name: Some("acme-corp".to_string()),
233            backend: Some("sqlite".to_string()),
234            sqlite_path: Some("/shared/org/index.db".to_string()),
235            ..Default::default()
236        };
237
238        let backend = OrgBackendConfig::from_config_file(&file);
239        assert!(matches!(backend, OrgBackendConfig::SqliteShared { .. }));
240        assert!(backend.is_configured());
241        assert_eq!(backend.backend_type(), "sqlite");
242    }
243
244    #[test]
245    fn test_org_backend_postgresql() {
246        let file = ConfigFileOrg {
247            name: Some("acme-corp".to_string()),
248            backend: Some("postgresql".to_string()),
249            postgres_url: Some("postgresql://user:pass@localhost:5432/subcog".to_string()),
250            postgres_max_connections: Some(20),
251            postgres_timeout_secs: Some(60),
252            ..Default::default()
253        };
254
255        let backend = OrgBackendConfig::from_config_file(&file);
256        assert!(matches!(backend, OrgBackendConfig::Postgresql { .. }));
257        assert!(backend.is_configured());
258        assert_eq!(backend.backend_type(), "postgresql");
259
260        if let OrgBackendConfig::Postgresql {
261            max_connections,
262            timeout_secs,
263            ..
264        } = backend
265        {
266            assert_eq!(max_connections, 20);
267            assert_eq!(timeout_secs, 60);
268        }
269    }
270
271    #[test]
272    fn test_org_backend_none() {
273        let file = ConfigFileOrg::default();
274        let backend = OrgBackendConfig::from_config_file(&file);
275        assert!(matches!(backend, OrgBackendConfig::None));
276        assert!(!backend.is_configured());
277    }
278
279    #[test]
280    fn test_org_config_from_file_enabled() {
281        let file = ConfigFileOrg {
282            name: Some("test-org".to_string()),
283            backend: Some("sqlite".to_string()),
284            sqlite_path: Some("/tmp/org.db".to_string()),
285            ..Default::default()
286        };
287
288        // With org_scope_enabled = true
289        let config = OrgConfig::from_config_file(&file, true);
290        assert_eq!(config.name.as_deref(), Some("test-org"));
291        assert!(config.enabled);
292        assert!(config.is_available());
293    }
294
295    #[test]
296    fn test_org_config_from_file_disabled() {
297        let file = ConfigFileOrg {
298            name: Some("test-org".to_string()),
299            backend: Some("sqlite".to_string()),
300            sqlite_path: Some("/tmp/org.db".to_string()),
301            ..Default::default()
302        };
303
304        // With org_scope_enabled = false
305        let config = OrgConfig::from_config_file(&file, false);
306        assert!(!config.enabled);
307        assert!(!config.is_available());
308    }
309
310    #[test]
311    fn test_org_config_sqlite_missing_path() {
312        let file = ConfigFileOrg {
313            name: Some("test-org".to_string()),
314            backend: Some("sqlite".to_string()),
315            sqlite_path: None, // Missing!
316            ..Default::default()
317        };
318
319        let backend = OrgBackendConfig::from_config_file(&file);
320        assert!(matches!(backend, OrgBackendConfig::None));
321    }
322
323    #[test]
324    fn test_name_or_default() {
325        let config = OrgConfig::default();
326        assert_eq!(config.name_or_default(), "default");
327
328        let config = OrgConfig {
329            name: Some("my-org".to_string()),
330            ..Default::default()
331        };
332        assert_eq!(config.name_or_default(), "my-org");
333    }
334}