Skip to main content

guild_cli/config/
project.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::config::types::{DependsOn, ProjectName, TargetName};
7use crate::error::ConfigError;
8
9/// Raw deserialization target for a project `guild.toml`.
10#[derive(Debug, Deserialize)]
11struct ProjectToml {
12    project: ProjectSection,
13    #[serde(default)]
14    targets: HashMap<TargetName, TargetSection>,
15}
16
17#[derive(Debug, Deserialize)]
18struct ProjectSection {
19    name: ProjectName,
20    #[serde(default)]
21    tags: Vec<String>,
22    #[serde(default)]
23    depends_on: Vec<ProjectName>,
24}
25
26#[derive(Debug, Deserialize)]
27struct TargetSection {
28    command: String,
29    #[serde(default)]
30    depends_on: Vec<DependsOn>,
31    #[serde(default)]
32    inputs: Vec<String>,
33    #[serde(default)]
34    outputs: Vec<String>,
35}
36
37/// A validated project configuration parsed from a project's `guild.toml`.
38#[derive(Debug, Clone)]
39pub struct ProjectConfig {
40    name: ProjectName,
41    tags: Vec<String>,
42    depends_on: Vec<ProjectName>,
43    targets: HashMap<TargetName, TargetConfig>,
44    root: PathBuf,
45}
46
47/// A validated target configuration.
48#[derive(Debug, Clone)]
49pub struct TargetConfig {
50    command: String,
51    depends_on: Vec<DependsOn>,
52    inputs: Vec<String>,
53    outputs: Vec<String>,
54}
55
56impl ProjectConfig {
57    /// Parse a project configuration from the given `guild.toml` path.
58    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
59        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
60            path: path.to_path_buf(),
61            source: e,
62        })?;
63        let root = path
64            .parent()
65            .expect("guild.toml must have a parent directory")
66            .to_path_buf();
67        Self::from_str(&content, root)
68    }
69
70    /// Parse a project configuration from a TOML string (for testing).
71    pub fn from_str(content: &str, root: PathBuf) -> Result<Self, ConfigError> {
72        let raw: ProjectToml = toml::from_str(content).map_err(|e| ConfigError::ParseToml {
73            path: root.join("guild.toml"),
74            source: e,
75        })?;
76        let targets = raw
77            .targets
78            .into_iter()
79            .map(|(name, section)| {
80                let config = TargetConfig {
81                    command: section.command,
82                    depends_on: section.depends_on,
83                    inputs: section.inputs,
84                    outputs: section.outputs,
85                };
86                (name, config)
87            })
88            .collect();
89        Ok(Self {
90            name: raw.project.name,
91            tags: raw.project.tags,
92            depends_on: raw.project.depends_on,
93            targets,
94            root,
95        })
96    }
97
98    pub fn name(&self) -> &ProjectName {
99        &self.name
100    }
101
102    pub fn tags(&self) -> &[String] {
103        &self.tags
104    }
105
106    pub fn depends_on(&self) -> &[ProjectName] {
107        &self.depends_on
108    }
109
110    pub fn targets(&self) -> &HashMap<TargetName, TargetConfig> {
111        &self.targets
112    }
113
114    pub fn root(&self) -> &Path {
115        &self.root
116    }
117}
118
119impl TargetConfig {
120    pub fn command(&self) -> &str {
121        &self.command
122    }
123
124    pub fn depends_on(&self) -> &[DependsOn] {
125        &self.depends_on
126    }
127
128    pub fn inputs(&self) -> &[String] {
129        &self.inputs
130    }
131
132    pub fn outputs(&self) -> &[String] {
133        &self.outputs
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_parse_project_config() {
143        let toml = r#"
144[project]
145name = "my-app"
146tags = ["app", "typescript"]
147depends_on = ["shared-lib"]
148
149[targets.build]
150command = "npm run build"
151depends_on = ["^build"]
152
153[targets.test]
154command = "npm test"
155depends_on = ["build"]
156
157[targets.lint]
158command = "npm run lint"
159"#;
160        let config = ProjectConfig::from_str(toml, PathBuf::from("/tmp/my-app")).unwrap();
161        assert_eq!(config.name().as_str(), "my-app");
162        assert_eq!(config.tags(), &["app", "typescript"]);
163        assert_eq!(config.depends_on().len(), 1);
164        assert_eq!(config.depends_on()[0].as_str(), "shared-lib");
165        assert_eq!(config.targets().len(), 3);
166
167        let build = &config.targets()[&"build".parse::<TargetName>().unwrap()];
168        assert_eq!(build.command(), "npm run build");
169        assert_eq!(build.depends_on().len(), 1);
170        assert!(build.depends_on()[0].is_upstream());
171    }
172
173    #[test]
174    fn test_parse_minimal_project() {
175        let toml = r#"
176[project]
177name = "minimal"
178"#;
179        let config = ProjectConfig::from_str(toml, PathBuf::from("/tmp/minimal")).unwrap();
180        assert_eq!(config.name().as_str(), "minimal");
181        assert!(config.tags().is_empty());
182        assert!(config.depends_on().is_empty());
183        assert!(config.targets().is_empty());
184    }
185
186    #[test]
187    fn test_parse_project_invalid_name() {
188        let toml = r#"
189[project]
190name = "My App"
191"#;
192        assert!(ProjectConfig::from_str(toml, PathBuf::from("/tmp")).is_err());
193    }
194
195    #[test]
196    fn test_target_with_inputs_outputs() {
197        let toml = r#"
198[project]
199name = "my-app"
200
201[targets.build]
202command = "cargo build"
203inputs = ["src/**/*.rs", "Cargo.toml"]
204outputs = ["target/release/my-app"]
205"#;
206        let config = ProjectConfig::from_str(toml, PathBuf::from("/tmp/my-app")).unwrap();
207        let build = &config.targets()[&"build".parse::<TargetName>().unwrap()];
208        assert_eq!(build.inputs(), &["src/**/*.rs", "Cargo.toml"]);
209        assert_eq!(build.outputs(), &["target/release/my-app"]);
210    }
211}