Skip to main content

orodruin_cli/
config.rs

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}