1use std::{
2 collections::{BTreeMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10pub const CONFIG_FILE_NAME: &str = "orodruin.toml";
11
12#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13pub struct ProjectConfig {
14 #[serde(default)]
15 pub project: ProjectMetadata,
16 pub envs: BTreeMap<String, EnvironmentConfig>,
17}
18
19#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
20pub struct ProjectMetadata {
21 pub name: Option<String>,
22 pub default_env: Option<String>,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
26pub struct EnvironmentConfig {
27 pub image: Option<String>,
28 pub build: Option<BuildConfig>,
29 pub container_name: Option<String>,
30 pub project_mount: Option<String>,
31 pub workdir: Option<String>,
32 #[serde(default)]
33 pub env: BTreeMap<String, String>,
34 #[serde(default)]
35 pub preserve_env: Vec<String>,
36 #[serde(default)]
37 pub mounts: Vec<MountConfig>,
38 pub shell: Option<Vec<String>>,
39 pub startup_command: Option<Vec<String>>,
40 pub default_command: Option<Vec<String>>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
44pub struct BuildConfig {
45 pub context: String,
46 pub file: Option<String>,
47 pub tag: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
51pub struct MountConfig {
52 pub source: String,
53 pub target: String,
54 #[serde(default)]
55 pub readonly: bool,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LoadedConfig {
60 pub root: PathBuf,
61 pub path: PathBuf,
62 pub config: ProjectConfig,
63}
64
65#[derive(Debug, Error)]
66pub enum ConfigError {
67 #[error("could not find {CONFIG_FILE_NAME} from {0}")]
68 NotFound(String),
69 #[error("failed to read config at {path}: {source}")]
70 Read {
71 path: PathBuf,
72 #[source]
73 source: std::io::Error,
74 },
75 #[error("failed to parse config at {path}: {source}")]
76 Parse {
77 path: PathBuf,
78 #[source]
79 source: toml::de::Error,
80 },
81 #[error("failed to serialize default config: {0}")]
82 Serialize(#[from] toml::ser::Error),
83 #[error("invalid config: {0}")]
84 Validation(String),
85}
86
87impl ProjectConfig {
88 pub fn load_from(
89 start_dir: &Path,
90 explicit_path: Option<&Path>,
91 ) -> Result<LoadedConfig, ConfigError> {
92 let path = match explicit_path {
93 Some(path) => path.to_path_buf(),
94 None => find_config_path(start_dir)?,
95 };
96 let root = fs::canonicalize(path.parent().ok_or_else(|| {
97 ConfigError::Validation("config path has no parent directory".into())
98 })?)
99 .unwrap_or_else(|_| {
100 path.parent()
101 .expect("config path parent checked above")
102 .to_path_buf()
103 });
104 let content = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
105 path: path.clone(),
106 source,
107 })?;
108 let config =
109 toml::from_str::<ProjectConfig>(&content).map_err(|source| ConfigError::Parse {
110 path: path.clone(),
111 source,
112 })?;
113 config.validate()?;
114 Ok(LoadedConfig { root, path, config })
115 }
116
117 pub fn validate(&self) -> Result<(), ConfigError> {
118 if self.envs.is_empty() {
119 return Err(ConfigError::Validation(
120 "define at least one environment under [envs.<name>]".into(),
121 ));
122 }
123
124 if let Some(default_env) = self.project.default_env.as_deref() {
125 if default_env.trim().is_empty() {
126 return Err(ConfigError::Validation(
127 "project.default_env must not be empty".into(),
128 ));
129 }
130 if !self.envs.contains_key(default_env) {
131 return Err(ConfigError::Validation(format!(
132 "project.default_env `{default_env}` is not defined under [envs.<name>]"
133 )));
134 }
135 }
136
137 for (name, env) in &self.envs {
138 let has_image = env.image.is_some();
139 let has_build = env.build.is_some();
140 if has_image == has_build {
141 return Err(ConfigError::Validation(format!(
142 "environment `{name}` must define exactly one of `image` or `build`"
143 )));
144 }
145
146 if let Some(project_mount) = &env.project_mount {
147 validate_absolute_path(name, "project_mount", project_mount)?;
148 }
149 if let Some(workdir) = &env.workdir {
150 validate_absolute_path(name, "workdir", workdir)?;
151 }
152 if let Some(build) = &env.build
153 && build.context.trim().is_empty()
154 {
155 return Err(ConfigError::Validation(format!(
156 "environment `{name}` has an empty build context"
157 )));
158 }
159
160 let mut preserve_env = HashSet::new();
161 for key in &env.preserve_env {
162 if key.trim().is_empty() {
163 return Err(ConfigError::Validation(format!(
164 "environment `{name}` contains an empty preserve_env entry"
165 )));
166 }
167 preserve_env.insert(key);
168 }
169 for mount in &env.mounts {
170 if mount.source.trim().is_empty() || mount.target.trim().is_empty() {
171 return Err(ConfigError::Validation(format!(
172 "environment `{name}` mounts must define non-empty source and target"
173 )));
174 }
175 validate_absolute_path(name, "mount target", &mount.target)?;
176 }
177 if let Some(shell) = &env.shell
178 && shell.is_empty()
179 {
180 return Err(ConfigError::Validation(format!(
181 "environment `{name}` shell must contain at least one token"
182 )));
183 }
184 if let Some(startup_command) = &env.startup_command
185 && startup_command.is_empty()
186 {
187 return Err(ConfigError::Validation(format!(
188 "environment `{name}` startup_command must contain at least one token"
189 )));
190 }
191 if let Some(default_command) = &env.default_command
192 && default_command.is_empty()
193 {
194 return Err(ConfigError::Validation(format!(
195 "environment `{name}` default_command must contain at least one token"
196 )));
197 }
198 }
199
200 Ok(())
201 }
202}
203
204pub fn default_init_config(project_name: &str) -> Result<String, ConfigError> {
205 let mut envs = BTreeMap::new();
206 envs.insert(
207 "dev".to_string(),
208 EnvironmentConfig {
209 image: Some("ubuntu:latest".into()),
210 build: None,
211 container_name: None,
212 project_mount: Some(format!("/workspace/{project_name}")),
213 workdir: Some(format!("/workspace/{project_name}")),
214 env: BTreeMap::new(),
215 preserve_env: vec!["SSH_AUTH_SOCK".into()],
216 mounts: vec![],
217 shell: Some(vec!["/bin/bash".into()]),
218 startup_command: Some(vec!["sleep".into(), "infinity".into()]),
219 default_command: None,
220 },
221 );
222
223 toml::to_string_pretty(&ProjectConfig {
224 project: ProjectMetadata {
225 name: Some(project_name.to_string()),
226 default_env: None,
227 },
228 envs,
229 })
230 .map_err(ConfigError::from)
231}
232
233fn validate_absolute_path(env_name: &str, field: &str, path: &str) -> Result<(), ConfigError> {
234 if !path.starts_with('/') {
235 return Err(ConfigError::Validation(format!(
236 "environment `{env_name}` {field} must be an absolute path"
237 )));
238 }
239 Ok(())
240}
241
242fn find_config_path(start_dir: &Path) -> Result<PathBuf, ConfigError> {
243 for directory in start_dir.ancestors() {
244 let candidate = directory.join(CONFIG_FILE_NAME);
245 if candidate.is_file() {
246 return Ok(candidate);
247 }
248 }
249 Err(ConfigError::NotFound(start_dir.display().to_string()))
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn parses_minimal_valid_config() {
258 let config: ProjectConfig = toml::from_str(
259 r#"
260 [envs.dev]
261 image = "ubuntu:latest"
262 "#,
263 )
264 .unwrap();
265
266 config.validate().unwrap();
267 assert!(config.envs.contains_key("dev"));
268 }
269
270 #[test]
271 fn parses_multiple_environments() {
272 let config: ProjectConfig = toml::from_str(
273 r#"
274 [envs.dev]
275 image = "ubuntu:latest"
276
277 [envs.ci]
278 build = { context = ".", file = "Containerfile" }
279 "#,
280 )
281 .unwrap();
282
283 config.validate().unwrap();
284 assert_eq!(config.envs.len(), 2);
285 }
286
287 #[test]
288 fn rejects_invalid_mount_target() {
289 let config: ProjectConfig = toml::from_str(
290 r#"
291 [envs.dev]
292 image = "ubuntu:latest"
293 mounts = [{ source = ".", target = "workspace" }]
294 "#,
295 )
296 .unwrap();
297
298 let error = config.validate().unwrap_err();
299 assert!(
300 error
301 .to_string()
302 .contains("mount target must be an absolute path")
303 );
304 }
305
306 #[test]
307 fn rejects_missing_image_or_build() {
308 let config: ProjectConfig = toml::from_str(
309 r#"
310 [envs.dev]
311 workdir = "/workspace/app"
312 "#,
313 )
314 .unwrap();
315
316 let error = config.validate().unwrap_err();
317 assert!(
318 error
319 .to_string()
320 .contains("must define exactly one of `image` or `build`")
321 );
322 }
323
324 #[test]
325 fn rejects_unknown_default_env() {
326 let config: ProjectConfig = toml::from_str(
327 r#"
328 [project]
329 default_env = "ci"
330
331 [envs.dev]
332 image = "ubuntu:latest"
333 "#,
334 )
335 .unwrap();
336
337 let error = config.validate().unwrap_err();
338 assert!(
339 error
340 .to_string()
341 .contains("project.default_env `ci` is not defined")
342 );
343 }
344
345 #[test]
346 fn default_init_config_serializes_escaped_project_name() {
347 let rendered = default_init_config("demo \"quoted\"").unwrap();
348 let config: ProjectConfig = toml::from_str(&rendered).unwrap();
349
350 assert_eq!(config.project.name.as_deref(), Some("demo \"quoted\""));
351 let env = config.envs.get("dev").unwrap();
352 assert_eq!(
353 env.project_mount.as_deref(),
354 Some("/workspace/demo \"quoted\"")
355 );
356 assert_eq!(env.workdir.as_deref(), Some("/workspace/demo \"quoted\""));
357 assert_eq!(
358 env.shell.as_deref(),
359 Some([String::from("/bin/bash")].as_slice())
360 );
361 }
362}