1use crate::error::LoadError;
4use sage_package::{parse_dependencies, DependencySpec};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[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#[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#[derive(Debug, Clone, Deserialize)]
37pub struct DatabaseToolConfig {
38 pub driver: String,
40 pub url: String,
42 #[serde(default = "default_pool_size")]
44 pub pool_size: u32,
45}
46
47fn default_pool_size() -> u32 {
48 5
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct HttpToolConfig {
54 #[serde(default = "default_http_timeout")]
56 pub timeout_ms: u64,
57}
58
59fn default_http_timeout() -> u64 {
60 30_000 }
62
63#[derive(Debug, Clone, Deserialize)]
65pub struct FileSystemToolConfig {
66 pub root: PathBuf,
68}
69
70#[derive(Debug, Clone, Deserialize)]
72pub struct PersistenceConfig {
73 #[serde(default = "default_persistence_backend")]
75 pub backend: String,
76 #[serde(default = "default_persistence_path")]
78 pub path: String,
79 #[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#[derive(Debug, Clone, Deserialize)]
104pub struct SupervisionConfig {
105 #[serde(default = "default_max_restarts")]
107 pub max_restarts: u32,
108 #[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#[derive(Debug, Clone, Deserialize)]
132pub struct ObservabilityConfig {
133 #[serde(default = "default_observability_backend")]
135 pub backend: String,
136 #[serde(default)]
138 pub otlp_endpoint: Option<String>,
139 #[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#[derive(Debug, Clone, Deserialize)]
164pub struct TestConfig {
165 #[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 }
181
182#[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 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 pub fn find(start_dir: &Path) -> Option<PathBuf> {
221 let mut current = start_dir.to_path_buf();
222 loop {
223 let grove_path = current.join("grove.toml");
225 if grove_path.exists() {
226 return Some(grove_path);
227 }
228 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 pub fn has_dependencies(&self) -> bool {
241 !self.dependencies.is_empty()
242 }
243
244 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); }
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}