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}
18
19/// The [test] section of grove.toml.
20#[derive(Debug, Clone, Deserialize)]
21pub struct TestConfig {
22    /// Test timeout in milliseconds (default: 10000)
23    #[serde(default = "default_timeout_ms")]
24    pub timeout_ms: u64,
25}
26
27impl Default for TestConfig {
28    fn default() -> Self {
29        Self {
30            timeout_ms: default_timeout_ms(),
31        }
32    }
33}
34
35fn default_timeout_ms() -> u64 {
36    10_000 // 10 seconds
37}
38
39/// The [project] section of grove.toml.
40#[derive(Debug, Clone, Deserialize)]
41pub struct ProjectConfig {
42    pub name: String,
43    #[serde(default = "default_version")]
44    pub version: String,
45    #[serde(default = "default_entry")]
46    pub entry: PathBuf,
47}
48
49fn default_version() -> String {
50    "0.1.0".to_string()
51}
52
53fn default_entry() -> PathBuf {
54    PathBuf::from("src/main.sg")
55}
56
57impl ProjectManifest {
58    /// Load a manifest from a grove.toml file.
59    pub fn load(path: &Path) -> Result<Self, LoadError> {
60        let contents = std::fs::read_to_string(path).map_err(|e| LoadError::IoError {
61            path: path.to_path_buf(),
62            source: e,
63        })?;
64
65        toml::from_str(&contents).map_err(|e| LoadError::InvalidManifest {
66            path: path.to_path_buf(),
67            source: e,
68        })
69    }
70
71    /// Find a grove.toml file by searching upward from the given directory.
72    /// Falls back to sage.toml for backwards compatibility.
73    pub fn find(start_dir: &Path) -> Option<PathBuf> {
74        let mut current = start_dir.to_path_buf();
75        loop {
76            // Try grove.toml first
77            let grove_path = current.join("grove.toml");
78            if grove_path.exists() {
79                return Some(grove_path);
80            }
81            // Fall back to sage.toml (deprecated)
82            let sage_path = current.join("sage.toml");
83            if sage_path.exists() {
84                return Some(sage_path);
85            }
86            if !current.pop() {
87                return None;
88            }
89        }
90    }
91
92    /// Check if the project has any dependencies declared.
93    pub fn has_dependencies(&self) -> bool {
94        !self.dependencies.is_empty()
95    }
96
97    /// Parse the dependencies table into structured specs.
98    pub fn parse_dependencies(&self) -> Result<HashMap<String, DependencySpec>, LoadError> {
99        parse_dependencies(&self.dependencies).map_err(|e| LoadError::PackageError { source: e })
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn parse_minimal_manifest() {
109        let toml = r#"
110[project]
111name = "test"
112"#;
113        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
114        assert_eq!(manifest.project.name, "test");
115        assert_eq!(manifest.project.version, "0.1.0");
116        assert_eq!(manifest.project.entry, PathBuf::from("src/main.sg"));
117    }
118
119    #[test]
120    fn parse_full_manifest() {
121        let toml = r#"
122[project]
123name = "research"
124version = "1.2.3"
125entry = "src/app.sg"
126
127[dependencies]
128"#;
129        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
130        assert_eq!(manifest.project.name, "research");
131        assert_eq!(manifest.project.version, "1.2.3");
132        assert_eq!(manifest.project.entry, PathBuf::from("src/app.sg"));
133    }
134
135    #[test]
136    fn parse_test_config_default() {
137        let toml = r#"
138[project]
139name = "test"
140"#;
141        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
142        assert_eq!(manifest.test.timeout_ms, 10_000);
143    }
144
145    #[test]
146    fn parse_test_config_custom_timeout() {
147        let toml = r#"
148[project]
149name = "test"
150
151[test]
152timeout_ms = 30000
153"#;
154        let manifest: ProjectManifest = toml::from_str(toml).unwrap();
155        assert_eq!(manifest.test.timeout_ms, 30_000);
156    }
157}