Skip to main content

sage_loader/
manifest.rs

1//! Project manifest (grove.toml) parsing.
2
3use crate::error::LoadError;
4use sage_package::{parse_dependencies, DependencySpec};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9/// A Sage project manifest (grove.toml).
10#[derive(Debug, Clone, Deserialize)]
11pub struct ProjectManifest {
12    pub project: ProjectConfig,
13    #[serde(default)]
14    pub dependencies: toml::Table,
15    #[serde(default)]
16    pub test: TestConfig,
17    #[serde(default)]
18    pub tools: ToolsConfig,
19    #[serde(default)]
20    pub persistence: PersistenceConfig,
21    #[serde(default)]
22    pub supervision: SupervisionConfig,
23    #[serde(default)]
24    pub observability: ObservabilityConfig,
25}
26
27/// Tool configuration section of grove.toml.
28#[derive(Debug, Clone, Deserialize, Default)]
29pub struct ToolsConfig {
30    pub database: Option<DatabaseToolConfig>,
31    pub http: Option<HttpToolConfig>,
32    pub filesystem: Option<FileSystemToolConfig>,
33}
34
35/// Database tool configuration.
36#[derive(Debug, Clone, Deserialize)]
37pub struct DatabaseToolConfig {
38    /// Database driver: "postgres", "sqlite", etc.
39    pub driver: String,
40    /// Connection URL.
41    pub url: String,
42    /// Connection pool size.
43    #[serde(default = "default_pool_size")]
44    pub pool_size: u32,
45}
46
47fn default_pool_size() -> u32 {
48    5
49}
50
51/// HTTP tool configuration.
52#[derive(Debug, Clone, Deserialize)]
53pub struct HttpToolConfig {
54    /// Request timeout in milliseconds.
55    #[serde(default = "default_http_timeout")]
56    pub timeout_ms: u64,
57}
58
59fn default_http_timeout() -> u64 {
60    30_000 // 30 seconds
61}
62
63/// FileSystem tool configuration.
64#[derive(Debug, Clone, Deserialize)]
65pub struct FileSystemToolConfig {
66    /// Root directory for filesystem operations.
67    pub root: PathBuf,
68}
69
70/// Persistence configuration for @persistent agent fields.
71#[derive(Debug, Clone, Deserialize)]
72pub struct PersistenceConfig {
73    /// Storage backend: "sqlite" (default), "postgres", or "file".
74    #[serde(default = "default_persistence_backend")]
75    pub backend: String,
76    /// Path for file-based backends (sqlite, file).
77    #[serde(default = "default_persistence_path")]
78    pub path: String,
79    /// Connection URL for postgres backend.
80    #[serde(default)]
81    pub url: Option<String>,
82}
83
84impl Default for PersistenceConfig {
85    fn default() -> Self {
86        Self {
87            backend: default_persistence_backend(),
88            path: default_persistence_path(),
89            url: None,
90        }
91    }
92}
93
94fn default_persistence_backend() -> String {
95    "sqlite".to_string()
96}
97
98fn default_persistence_path() -> String {
99    ".sage/checkpoints.db".to_string()
100}
101
102/// Supervision configuration for supervisor restart intensity limiting.
103#[derive(Debug, Clone, Deserialize)]
104pub struct SupervisionConfig {
105    /// Maximum number of restarts allowed within the time window.
106    #[serde(default = "default_max_restarts")]
107    pub max_restarts: u32,
108    /// Time window in seconds for restart counting.
109    #[serde(default = "default_within_seconds")]
110    pub within_seconds: u64,
111}
112
113impl Default for SupervisionConfig {
114    fn default() -> Self {
115        Self {
116            max_restarts: default_max_restarts(),
117            within_seconds: default_within_seconds(),
118        }
119    }
120}
121
122fn default_max_restarts() -> u32 {
123    5
124}
125
126fn default_within_seconds() -> u64 {
127    60
128}
129
130/// Observability configuration for tracing and metrics export.
131#[derive(Debug, Clone, Deserialize)]
132pub struct ObservabilityConfig {
133    /// Tracing backend: "ndjson" (default), "otlp", or "none".
134    #[serde(default = "default_observability_backend")]
135    pub backend: String,
136    /// OTLP endpoint for trace export (when backend = "otlp").
137    #[serde(default)]
138    pub otlp_endpoint: Option<String>,
139    /// Service name for trace attribution.
140    #[serde(default = "default_service_name_option")]
141    pub service_name: Option<String>,
142}
143
144impl Default for ObservabilityConfig {
145    fn default() -> Self {
146        Self {
147            backend: default_observability_backend(),
148            otlp_endpoint: None,
149            service_name: default_service_name_option(),
150        }
151    }
152}
153
154fn default_observability_backend() -> String {
155    "ndjson".to_string()
156}
157
158fn default_service_name_option() -> Option<String> {
159    Some("sage-agent".to_string())
160}
161
162/// The [test] section of grove.toml.
163#[derive(Debug, Clone, Deserialize)]
164pub struct TestConfig {
165    /// Test timeout in milliseconds (default: 10000)
166    #[serde(default = "default_timeout_ms")]
167    pub timeout_ms: u64,
168}
169
170impl Default for TestConfig {
171    fn default() -> Self {
172        Self {
173            timeout_ms: default_timeout_ms(),
174        }
175    }
176}
177
178fn default_timeout_ms() -> u64 {
179    10_000 // 10 seconds
180}
181
182/// The [project] section of grove.toml.
183#[derive(Debug, Clone, Deserialize)]
184pub struct ProjectConfig {
185    pub name: String,
186    #[serde(default = "default_version")]
187    pub version: String,
188    #[serde(default = "default_entry")]
189    pub entry: PathBuf,
190}
191
192fn default_version() -> String {
193    "0.1.0".to_string()
194}
195
196fn default_entry() -> PathBuf {
197    PathBuf::from("src/main.sg")
198}
199
200impl ProjectManifest {
201    /// Load a manifest from a grove.toml file.
202    pub fn load(path: &Path) -> Result<Self, Box<LoadError>> {
203        let contents = std::fs::read_to_string(path).map_err(|e| {
204            Box::new(LoadError::IoError {
205                path: path.to_path_buf(),
206                source: e,
207            })
208        })?;
209
210        toml::from_str(&contents).map_err(|e| {
211            Box::new(LoadError::InvalidManifest {
212                path: path.to_path_buf(),
213                source: e,
214            })
215        })
216    }
217
218    /// Find a grove.toml file by searching upward from the given directory.
219    /// Falls back to sage.toml for backwards compatibility.
220    pub fn find(start_dir: &Path) -> Option<PathBuf> {
221        let mut current = start_dir.to_path_buf();
222        loop {
223            // Try grove.toml first
224            let grove_path = current.join("grove.toml");
225            if grove_path.exists() {
226                return Some(grove_path);
227            }
228            // Fall back to sage.toml (deprecated)
229            let sage_path = current.join("sage.toml");
230            if sage_path.exists() {
231                return Some(sage_path);
232            }
233            if !current.pop() {
234                return None;
235            }
236        }
237    }
238
239    /// Check if the project has any dependencies declared.
240    pub fn has_dependencies(&self) -> bool {
241        !self.dependencies.is_empty()
242    }
243
244    /// Parse the dependencies table into structured specs.
245    pub fn parse_dependencies(&self) -> Result<HashMap<String, DependencySpec>, Box<LoadError>> {
246        parse_dependencies(&self.dependencies)
247            .map_err(|e| Box::new(LoadError::PackageError { source: e }))
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn parse_minimal_manifest() {
257        let toml = r#"
258[project]
259name = "test"
260"#;
261        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
262        assert_eq!(manifest.project.name, "test");
263        assert_eq!(manifest.project.version, "0.1.0");
264        assert_eq!(manifest.project.entry, PathBuf::from("src/main.sg"));
265    }
266
267    #[test]
268    fn parse_full_manifest() {
269        let toml = r#"
270[project]
271name = "research"
272version = "1.2.3"
273entry = "src/app.sg"
274
275[dependencies]
276"#;
277        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
278        assert_eq!(manifest.project.name, "research");
279        assert_eq!(manifest.project.version, "1.2.3");
280        assert_eq!(manifest.project.entry, PathBuf::from("src/app.sg"));
281    }
282
283    #[test]
284    fn parse_test_config_default() {
285        let toml = r#"
286[project]
287name = "test"
288"#;
289        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
290        assert_eq!(manifest.test.timeout_ms, 10_000);
291    }
292
293    #[test]
294    fn parse_test_config_custom_timeout() {
295        let toml = r#"
296[project]
297name = "test"
298
299[test]
300timeout_ms = 30000
301"#;
302        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
303        assert_eq!(manifest.test.timeout_ms, 30_000);
304    }
305
306    #[test]
307    fn parse_tools_config_default() {
308        let toml = r#"
309[project]
310name = "test"
311"#;
312        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
313        assert!(manifest.tools.database.is_none());
314        assert!(manifest.tools.http.is_none());
315        assert!(manifest.tools.filesystem.is_none());
316    }
317
318    #[test]
319    fn parse_tools_database_config() {
320        let toml = r#"
321[project]
322name = "test"
323
324[tools.database]
325driver = "postgres"
326url = "postgresql://localhost/mydb"
327"#;
328        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
329        let db = manifest.tools.database.unwrap();
330        assert_eq!(db.driver, "postgres");
331        assert_eq!(db.url, "postgresql://localhost/mydb");
332        assert_eq!(db.pool_size, 5); // default
333    }
334
335    #[test]
336    fn parse_tools_database_config_with_pool() {
337        let toml = r#"
338[project]
339name = "test"
340
341[tools.database]
342driver = "sqlite"
343url = ":memory:"
344pool_size = 10
345"#;
346        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
347        let db = manifest.tools.database.unwrap();
348        assert_eq!(db.driver, "sqlite");
349        assert_eq!(db.pool_size, 10);
350    }
351
352    #[test]
353    fn parse_tools_http_config() {
354        let toml = r#"
355[project]
356name = "test"
357
358[tools.http]
359timeout_ms = 60000
360"#;
361        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
362        let http = manifest.tools.http.unwrap();
363        assert_eq!(http.timeout_ms, 60_000);
364    }
365
366    #[test]
367    fn parse_tools_filesystem_config() {
368        let toml = r#"
369[project]
370name = "test"
371
372[tools.filesystem]
373root = "/var/data"
374"#;
375        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
376        let fs = manifest.tools.filesystem.unwrap();
377        assert_eq!(fs.root, PathBuf::from("/var/data"));
378    }
379
380    #[test]
381    fn parse_tools_all_configs() {
382        let toml = r#"
383[project]
384name = "full-project"
385
386[tools.database]
387driver = "postgres"
388url = "postgresql://localhost/db"
389pool_size = 20
390
391[tools.http]
392timeout_ms = 5000
393
394[tools.filesystem]
395root = "./data"
396"#;
397        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
398        assert!(manifest.tools.database.is_some());
399        assert!(manifest.tools.http.is_some());
400        assert!(manifest.tools.filesystem.is_some());
401    }
402
403    #[test]
404    fn parse_persistence_default() {
405        let toml = r#"
406[project]
407name = "test"
408"#;
409        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
410        assert_eq!(manifest.persistence.backend, "sqlite");
411        assert_eq!(manifest.persistence.path, ".sage/checkpoints.db");
412        assert!(manifest.persistence.url.is_none());
413    }
414
415    #[test]
416    fn parse_persistence_sqlite() {
417        let toml = r#"
418[project]
419name = "test"
420
421[persistence]
422backend = "sqlite"
423path = "./checkpoints/data.db"
424"#;
425        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
426        assert_eq!(manifest.persistence.backend, "sqlite");
427        assert_eq!(manifest.persistence.path, "./checkpoints/data.db");
428    }
429
430    #[test]
431    fn parse_persistence_postgres() {
432        let toml = r#"
433[project]
434name = "test"
435
436[persistence]
437backend = "postgres"
438url = "postgresql://user:pass@localhost/mydb"
439"#;
440        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
441        assert_eq!(manifest.persistence.backend, "postgres");
442        assert_eq!(
443            manifest.persistence.url,
444            Some("postgresql://user:pass@localhost/mydb".to_string())
445        );
446    }
447
448    #[test]
449    fn parse_persistence_file() {
450        let toml = r#"
451[project]
452name = "test"
453
454[persistence]
455backend = "file"
456path = "./state"
457"#;
458        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
459        assert_eq!(manifest.persistence.backend, "file");
460        assert_eq!(manifest.persistence.path, "./state");
461    }
462
463    #[test]
464    fn parse_supervision_default() {
465        let toml = r#"
466[project]
467name = "test"
468"#;
469        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
470        assert_eq!(manifest.supervision.max_restarts, 5);
471        assert_eq!(manifest.supervision.within_seconds, 60);
472    }
473
474    #[test]
475    fn parse_supervision_custom() {
476        let toml = r#"
477[project]
478name = "test"
479
480[supervision]
481max_restarts = 10
482within_seconds = 120
483"#;
484        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
485        assert_eq!(manifest.supervision.max_restarts, 10);
486        assert_eq!(manifest.supervision.within_seconds, 120);
487    }
488
489    #[test]
490    fn parse_observability_default() {
491        let toml = r#"
492[project]
493name = "test"
494"#;
495        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
496        assert_eq!(manifest.observability.backend, "ndjson");
497        assert!(manifest.observability.otlp_endpoint.is_none());
498        assert_eq!(
499            manifest.observability.service_name,
500            Some("sage-agent".to_string())
501        );
502    }
503
504    #[test]
505    fn parse_observability_otlp() {
506        let toml = r#"
507[project]
508name = "test"
509
510[observability]
511backend = "otlp"
512otlp_endpoint = "http://localhost:4317"
513service_name = "my-service"
514"#;
515        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
516        assert_eq!(manifest.observability.backend, "otlp");
517        assert_eq!(
518            manifest.observability.otlp_endpoint,
519            Some("http://localhost:4317".to_string())
520        );
521        assert_eq!(
522            manifest.observability.service_name,
523            Some("my-service".to_string())
524        );
525    }
526
527    #[test]
528    fn parse_observability_none() {
529        let toml = r#"
530[project]
531name = "test"
532
533[observability]
534backend = "none"
535"#;
536        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
537        assert_eq!(manifest.observability.backend, "none");
538    }
539}