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, 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 pub fn find(start_dir: &Path) -> Option<PathBuf> {
217 let mut current = start_dir.to_path_buf();
218 loop {
219 let grove_path = current.join("grove.toml");
221 if grove_path.exists() {
222 return Some(grove_path);
223 }
224 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 pub fn has_dependencies(&self) -> bool {
237 !self.dependencies.is_empty()
238 }
239
240 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); }
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}