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, LoadError> {
203        let contents = std::fs::read_to_string(path).map_err(|e| LoadError::IoError {
204            path: path.to_path_buf(),
205            source: e,
206        })?;
207
208        toml::from_str(&contents).map_err(|e| LoadError::InvalidManifest {
209            path: path.to_path_buf(),
210            source: e,
211        })
212    }
213
214    /// Find a grove.toml file by searching upward from the given directory.
215    /// Falls back to sage.toml for backwards compatibility.
216    pub fn find(start_dir: &Path) -> Option<PathBuf> {
217        let mut current = start_dir.to_path_buf();
218        loop {
219            // Try grove.toml first
220            let grove_path = current.join("grove.toml");
221            if grove_path.exists() {
222                return Some(grove_path);
223            }
224            // Fall back to sage.toml (deprecated)
225            let sage_path = current.join("sage.toml");
226            if sage_path.exists() {
227                return Some(sage_path);
228            }
229            if !current.pop() {
230                return None;
231            }
232        }
233    }
234
235    /// Check if the project has any dependencies declared.
236    pub fn has_dependencies(&self) -> bool {
237        !self.dependencies.is_empty()
238    }
239
240    /// Parse the dependencies table into structured specs.
241    pub fn parse_dependencies(&self) -> Result<HashMap<String, DependencySpec>, LoadError> {
242        parse_dependencies(&self.dependencies).map_err(|e| LoadError::PackageError { source: e })
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn parse_minimal_manifest() {
252        let toml = r#"
253[project]
254name = "test"
255"#;
256        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
257        assert_eq!(manifest.project.name, "test");
258        assert_eq!(manifest.project.version, "0.1.0");
259        assert_eq!(manifest.project.entry, PathBuf::from("src/main.sg"));
260    }
261
262    #[test]
263    fn parse_full_manifest() {
264        let toml = r#"
265[project]
266name = "research"
267version = "1.2.3"
268entry = "src/app.sg"
269
270[dependencies]
271"#;
272        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
273        assert_eq!(manifest.project.name, "research");
274        assert_eq!(manifest.project.version, "1.2.3");
275        assert_eq!(manifest.project.entry, PathBuf::from("src/app.sg"));
276    }
277
278    #[test]
279    fn parse_test_config_default() {
280        let toml = r#"
281[project]
282name = "test"
283"#;
284        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
285        assert_eq!(manifest.test.timeout_ms, 10_000);
286    }
287
288    #[test]
289    fn parse_test_config_custom_timeout() {
290        let toml = r#"
291[project]
292name = "test"
293
294[test]
295timeout_ms = 30000
296"#;
297        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
298        assert_eq!(manifest.test.timeout_ms, 30_000);
299    }
300
301    #[test]
302    fn parse_tools_config_default() {
303        let toml = r#"
304[project]
305name = "test"
306"#;
307        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
308        assert!(manifest.tools.database.is_none());
309        assert!(manifest.tools.http.is_none());
310        assert!(manifest.tools.filesystem.is_none());
311    }
312
313    #[test]
314    fn parse_tools_database_config() {
315        let toml = r#"
316[project]
317name = "test"
318
319[tools.database]
320driver = "postgres"
321url = "postgresql://localhost/mydb"
322"#;
323        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
324        let db = manifest.tools.database.unwrap();
325        assert_eq!(db.driver, "postgres");
326        assert_eq!(db.url, "postgresql://localhost/mydb");
327        assert_eq!(db.pool_size, 5); // default
328    }
329
330    #[test]
331    fn parse_tools_database_config_with_pool() {
332        let toml = r#"
333[project]
334name = "test"
335
336[tools.database]
337driver = "sqlite"
338url = ":memory:"
339pool_size = 10
340"#;
341        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
342        let db = manifest.tools.database.unwrap();
343        assert_eq!(db.driver, "sqlite");
344        assert_eq!(db.pool_size, 10);
345    }
346
347    #[test]
348    fn parse_tools_http_config() {
349        let toml = r#"
350[project]
351name = "test"
352
353[tools.http]
354timeout_ms = 60000
355"#;
356        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
357        let http = manifest.tools.http.unwrap();
358        assert_eq!(http.timeout_ms, 60_000);
359    }
360
361    #[test]
362    fn parse_tools_filesystem_config() {
363        let toml = r#"
364[project]
365name = "test"
366
367[tools.filesystem]
368root = "/var/data"
369"#;
370        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
371        let fs = manifest.tools.filesystem.unwrap();
372        assert_eq!(fs.root, PathBuf::from("/var/data"));
373    }
374
375    #[test]
376    fn parse_tools_all_configs() {
377        let toml = r#"
378[project]
379name = "full-project"
380
381[tools.database]
382driver = "postgres"
383url = "postgresql://localhost/db"
384pool_size = 20
385
386[tools.http]
387timeout_ms = 5000
388
389[tools.filesystem]
390root = "./data"
391"#;
392        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
393        assert!(manifest.tools.database.is_some());
394        assert!(manifest.tools.http.is_some());
395        assert!(manifest.tools.filesystem.is_some());
396    }
397
398    #[test]
399    fn parse_persistence_default() {
400        let toml = r#"
401[project]
402name = "test"
403"#;
404        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
405        assert_eq!(manifest.persistence.backend, "sqlite");
406        assert_eq!(manifest.persistence.path, ".sage/checkpoints.db");
407        assert!(manifest.persistence.url.is_none());
408    }
409
410    #[test]
411    fn parse_persistence_sqlite() {
412        let toml = r#"
413[project]
414name = "test"
415
416[persistence]
417backend = "sqlite"
418path = "./checkpoints/data.db"
419"#;
420        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
421        assert_eq!(manifest.persistence.backend, "sqlite");
422        assert_eq!(manifest.persistence.path, "./checkpoints/data.db");
423    }
424
425    #[test]
426    fn parse_persistence_postgres() {
427        let toml = r#"
428[project]
429name = "test"
430
431[persistence]
432backend = "postgres"
433url = "postgresql://user:pass@localhost/mydb"
434"#;
435        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
436        assert_eq!(manifest.persistence.backend, "postgres");
437        assert_eq!(
438            manifest.persistence.url,
439            Some("postgresql://user:pass@localhost/mydb".to_string())
440        );
441    }
442
443    #[test]
444    fn parse_persistence_file() {
445        let toml = r#"
446[project]
447name = "test"
448
449[persistence]
450backend = "file"
451path = "./state"
452"#;
453        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
454        assert_eq!(manifest.persistence.backend, "file");
455        assert_eq!(manifest.persistence.path, "./state");
456    }
457
458    #[test]
459    fn parse_supervision_default() {
460        let toml = r#"
461[project]
462name = "test"
463"#;
464        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
465        assert_eq!(manifest.supervision.max_restarts, 5);
466        assert_eq!(manifest.supervision.within_seconds, 60);
467    }
468
469    #[test]
470    fn parse_supervision_custom() {
471        let toml = r#"
472[project]
473name = "test"
474
475[supervision]
476max_restarts = 10
477within_seconds = 120
478"#;
479        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
480        assert_eq!(manifest.supervision.max_restarts, 10);
481        assert_eq!(manifest.supervision.within_seconds, 120);
482    }
483
484    #[test]
485    fn parse_observability_default() {
486        let toml = r#"
487[project]
488name = "test"
489"#;
490        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
491        assert_eq!(manifest.observability.backend, "ndjson");
492        assert!(manifest.observability.otlp_endpoint.is_none());
493        assert_eq!(
494            manifest.observability.service_name,
495            Some("sage-agent".to_string())
496        );
497    }
498
499    #[test]
500    fn parse_observability_otlp() {
501        let toml = r#"
502[project]
503name = "test"
504
505[observability]
506backend = "otlp"
507otlp_endpoint = "http://localhost:4317"
508service_name = "my-service"
509"#;
510        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
511        assert_eq!(manifest.observability.backend, "otlp");
512        assert_eq!(
513            manifest.observability.otlp_endpoint,
514            Some("http://localhost:4317".to_string())
515        );
516        assert_eq!(
517            manifest.observability.service_name,
518            Some("my-service".to_string())
519        );
520    }
521
522    #[test]
523    fn parse_observability_none() {
524        let toml = r#"
525[project]
526name = "test"
527
528[observability]
529backend = "none"
530"#;
531        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
532        assert_eq!(manifest.observability.backend, "none");
533    }
534}