Skip to main content

opal/
config.rs

1use crate::{EngineChoice, EngineKind, runtime};
2use anyhow::{Context, Result, anyhow};
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Default, Deserialize)]
10#[serde(default)]
11pub struct OpalConfig {
12    pub ai: AiSettingsConfig,
13    pub container: Option<ContainerEngineConfig>,
14    pub jobs: Vec<JobOverrideConfig>,
15    #[serde(alias = "engine")]
16    pub engines: EngineSettings,
17    #[serde(rename = "registry")]
18    pub registries: Vec<RegistryAuth>,
19}
20
21#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default)]
23pub struct EngineSettings {
24    pub default: Option<EngineChoice>,
25    pub container: Option<ContainerEngineConfig>,
26    pub preserve_runtime_objects: bool,
27}
28
29#[derive(Debug, Clone)]
30pub struct AiSettingsConfig {
31    pub default_provider: Option<AiProviderConfig>,
32    pub tail_lines: usize,
33    pub save_analysis: bool,
34    pub prompts: AiPromptConfig,
35    pub codex: CodexAiConfig,
36    pub ollama: OllamaAiConfig,
37    save_analysis_override: Option<bool>,
38}
39
40impl Default for AiSettingsConfig {
41    fn default() -> Self {
42        Self {
43            default_provider: None,
44            tail_lines: 200,
45            save_analysis: true,
46            prompts: AiPromptConfig::default(),
47            codex: CodexAiConfig::default(),
48            ollama: OllamaAiConfig::default(),
49            save_analysis_override: None,
50        }
51    }
52}
53
54impl<'de> Deserialize<'de> for AiSettingsConfig {
55    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56    where
57        D: serde::Deserializer<'de>,
58    {
59        #[derive(Deserialize, Default)]
60        #[serde(default)]
61        struct RawAiSettingsConfig {
62            default_provider: Option<AiProviderConfig>,
63            tail_lines: usize,
64            save_analysis: Option<bool>,
65            prompts: AiPromptConfig,
66            codex: CodexAiConfig,
67            ollama: OllamaAiConfig,
68        }
69
70        let raw = RawAiSettingsConfig::deserialize(deserializer)?;
71        let mut settings = AiSettingsConfig {
72            default_provider: raw.default_provider,
73            prompts: raw.prompts,
74            codex: raw.codex,
75            ollama: raw.ollama,
76            ..AiSettingsConfig::default()
77        };
78        if raw.tail_lines != 0 {
79            settings.tail_lines = raw.tail_lines;
80        }
81        if let Some(value) = raw.save_analysis {
82            settings.save_analysis = value;
83            settings.save_analysis_override = Some(value);
84        }
85        Ok(settings)
86    }
87}
88
89#[derive(Debug, Clone, Default, Deserialize)]
90#[serde(default)]
91pub struct AiPromptConfig {
92    pub system_file: Option<String>,
93    pub job_analysis_file: Option<String>,
94}
95
96#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "lowercase")]
98pub enum AiProviderConfig {
99    Ollama,
100    Claude,
101    Codex,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105#[serde(default)]
106pub struct OllamaAiConfig {
107    pub host: String,
108    pub model: String,
109    pub system: Option<String>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
113#[serde(default)]
114pub struct CodexAiConfig {
115    pub command: String,
116    pub model: Option<String>,
117}
118
119impl Default for CodexAiConfig {
120    fn default() -> Self {
121        Self {
122            command: "codex".to_string(),
123            model: None,
124        }
125    }
126}
127
128impl Default for OllamaAiConfig {
129    fn default() -> Self {
130        Self {
131            host: "http://127.0.0.1:11434".to_string(),
132            model: String::new(),
133            system: None,
134        }
135    }
136}
137
138#[derive(Debug, Clone, Default, Deserialize)]
139#[serde(default)]
140pub struct ContainerEngineConfig {
141    pub arch: Option<String>,
142    pub cpus: Option<String>,
143    pub memory: Option<String>,
144    pub dns: Option<String>,
145}
146
147#[derive(Debug, Clone, Default, Deserialize)]
148#[serde(default)]
149pub struct JobOverrideConfig {
150    pub name: String,
151    pub arch: Option<String>,
152    pub privileged: Option<bool>,
153    pub cap_add: Vec<String>,
154    pub cap_drop: Vec<String>,
155}
156
157#[derive(Debug, Clone, Default)]
158pub struct ResolvedJobOverride {
159    pub arch: Option<String>,
160    pub privileged: bool,
161    pub cap_add: Vec<String>,
162    pub cap_drop: Vec<String>,
163}
164
165#[derive(Debug, Clone, Deserialize)]
166pub struct RegistryAuth {
167    pub server: String,
168    pub username: String,
169    pub password: Option<String>,
170    pub password_env: Option<String>,
171    #[serde(default)]
172    pub engines: Vec<String>,
173    pub scheme: Option<String>,
174}
175
176#[derive(Debug, Clone)]
177pub struct ResolvedRegistryAuth {
178    pub server: String,
179    pub username: String,
180    pub password: String,
181    pub scheme: Option<String>,
182}
183
184impl OpalConfig {
185    pub fn load(workdir: &Path) -> Result<Self> {
186        let mut merged = OpalConfig::default();
187        for path in runtime::config_dirs(workdir) {
188            if path.exists() {
189                let contents = fs::read_to_string(&path)
190                    .with_context(|| format!("failed to read {}", path.display()))?;
191                let mut parsed: OpalConfig = toml::from_str(&contents)
192                    .with_context(|| format!("failed to parse {}", path.display()))?;
193                parsed.resolve_relative_paths(&path);
194                merged.merge(parsed);
195            }
196        }
197        Ok(merged)
198    }
199
200    pub fn container_settings(&self) -> Option<&ContainerEngineConfig> {
201        if let Some(cfg) = self.container.as_ref() {
202            return Some(cfg);
203        }
204        self.engines.container.as_ref()
205    }
206
207    pub fn default_engine(&self) -> Option<EngineChoice> {
208        self.engines.default
209    }
210
211    pub fn preserve_runtime_objects(&self) -> bool {
212        self.engines.preserve_runtime_objects
213    }
214
215    pub fn ai_settings(&self) -> &AiSettingsConfig {
216        &self.ai
217    }
218
219    pub fn registry_auth_for(&self, engine: EngineKind) -> Result<Vec<ResolvedRegistryAuth>> {
220        let mut seen = HashSet::new();
221        let mut results = Vec::new();
222        for auth in &self.registries {
223            if !auth.applies_to(engine) {
224                continue;
225            }
226            let resolved = auth.resolve()?;
227            if seen.insert((resolved.server.clone(), resolved.username.clone())) {
228                results.push(resolved);
229            }
230        }
231        Ok(results)
232    }
233
234    pub fn job_override_for(&self, job_name: &str) -> Option<ResolvedJobOverride> {
235        let mut resolved = ResolvedJobOverride::default();
236        let mut matched = false;
237        for entry in &self.jobs {
238            if entry.name != job_name {
239                continue;
240            }
241            matched = true;
242            if let Some(value) = &entry.arch {
243                resolved.arch = Some(value.clone());
244            }
245            if let Some(value) = entry.privileged {
246                resolved.privileged = value;
247            }
248            if !entry.cap_add.is_empty() {
249                resolved.cap_add = entry.cap_add.clone();
250            }
251            if !entry.cap_drop.is_empty() {
252                resolved.cap_drop = entry.cap_drop.clone();
253            }
254        }
255        matched.then_some(resolved)
256    }
257
258    fn merge(&mut self, mut other: OpalConfig) {
259        self.ai.merge(other.ai);
260        if let Some(new_container) = other.container.take() {
261            match &mut self.container {
262                Some(existing) => existing.merge(new_container),
263                slot @ None => *slot = Some(new_container),
264            }
265        }
266        self.engines.merge(other.engines);
267        self.jobs.extend(other.jobs);
268        self.registries.extend(other.registries);
269    }
270
271    fn resolve_relative_paths(&mut self, config_path: &Path) {
272        self.ai.prompts.resolve_relative_paths(config_path);
273    }
274}
275
276impl AiSettingsConfig {
277    fn merge(&mut self, other: AiSettingsConfig) {
278        if let Some(provider) = other.default_provider {
279            self.default_provider = Some(provider);
280        }
281        if other.tail_lines != 0 {
282            self.tail_lines = other.tail_lines;
283        }
284        if let Some(value) = other.save_analysis_override {
285            self.save_analysis = value;
286            self.save_analysis_override = Some(value);
287        }
288        self.prompts.merge(other.prompts);
289        self.codex.merge(other.codex);
290        self.ollama.merge(other.ollama);
291    }
292}
293
294impl CodexAiConfig {
295    fn merge(&mut self, other: CodexAiConfig) {
296        if !other.command.is_empty() {
297            self.command = other.command;
298        }
299        if other.model.is_some() {
300            self.model = other.model;
301        }
302    }
303}
304
305impl AiPromptConfig {
306    fn merge(&mut self, other: AiPromptConfig) {
307        if other.system_file.is_some() {
308            self.system_file = other.system_file;
309        }
310        if other.job_analysis_file.is_some() {
311            self.job_analysis_file = other.job_analysis_file;
312        }
313    }
314
315    fn resolve_relative_paths(&mut self, config_path: &Path) {
316        let Some(base_dir) = config_path.parent() else {
317            return;
318        };
319        if let Some(path) = &mut self.system_file {
320            *path = resolve_path_string(base_dir, path);
321        }
322        if let Some(path) = &mut self.job_analysis_file {
323            *path = resolve_path_string(base_dir, path);
324        }
325    }
326}
327
328fn resolve_path_string(base_dir: &Path, value: &str) -> String {
329    let path = PathBuf::from(value);
330    if path.is_absolute() {
331        path.display().to_string()
332    } else {
333        base_dir.join(path).display().to_string()
334    }
335}
336
337impl OllamaAiConfig {
338    fn merge(&mut self, other: OllamaAiConfig) {
339        if !other.host.is_empty() {
340            self.host = other.host;
341        }
342        if !other.model.is_empty() {
343            self.model = other.model;
344        }
345        if other.system.is_some() {
346            self.system = other.system;
347        }
348    }
349}
350
351impl EngineSettings {
352    fn merge(&mut self, other: EngineSettings) {
353        if let Some(default) = other.default {
354            self.default = Some(default);
355        }
356        self.preserve_runtime_objects =
357            self.preserve_runtime_objects || other.preserve_runtime_objects;
358        if let Some(new_container) = other.container {
359            match &mut self.container {
360                Some(existing) => existing.merge(new_container),
361                slot @ None => *slot = Some(new_container),
362            }
363        }
364    }
365}
366
367impl ContainerEngineConfig {
368    fn merge(&mut self, other: ContainerEngineConfig) {
369        let ContainerEngineConfig {
370            arch,
371            cpus,
372            memory,
373            dns,
374        } = other;
375        if let Some(value) = arch {
376            self.arch = Some(value);
377        }
378        if let Some(value) = cpus {
379            self.cpus = Some(value);
380        }
381        if let Some(value) = memory {
382            self.memory = Some(value);
383        }
384        if let Some(value) = dns {
385            self.dns = Some(value);
386        }
387    }
388}
389
390impl RegistryAuth {
391    fn applies_to(&self, engine: EngineKind) -> bool {
392        if self.engines.is_empty() {
393            return true;
394        }
395        let target = engine_name(engine);
396        self.engines
397            .iter()
398            .any(|value| value.eq_ignore_ascii_case(target))
399    }
400
401    fn resolve(&self) -> Result<ResolvedRegistryAuth> {
402        let password = if let Some(env_name) = &self.password_env {
403            env::var(env_name).with_context(|| {
404                format!(
405                    "registry auth for '{}' missing env var {}",
406                    self.server, env_name
407                )
408            })?
409        } else if let Some(pass) = &self.password {
410            pass.clone()
411        } else {
412            return Err(anyhow!(
413                "registry auth for '{}' must specify password or password_env",
414                self.server
415            ));
416        };
417        Ok(ResolvedRegistryAuth {
418            server: self.server.clone(),
419            username: self.username.clone(),
420            password,
421            scheme: self.scheme.clone(),
422        })
423    }
424}
425
426fn engine_name(engine: EngineKind) -> &'static str {
427    match engine {
428        EngineKind::ContainerCli => "container",
429        EngineKind::Docker => "docker",
430        EngineKind::Podman => "podman",
431        EngineKind::Nerdctl => "nerdctl",
432        EngineKind::Orbstack => "orbstack",
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::{ContainerEngineConfig, JobOverrideConfig, OpalConfig};
439    use std::path::Path;
440
441    #[test]
442    fn container_config_merge_overrides_arch() {
443        let mut base = OpalConfig {
444            container: Some(ContainerEngineConfig {
445                arch: Some("x86_64".into()),
446                cpus: None,
447                memory: None,
448                dns: None,
449            }),
450            ..OpalConfig::default()
451        };
452
453        base.merge(OpalConfig {
454            container: Some(ContainerEngineConfig {
455                arch: Some("arm64".into()),
456                cpus: None,
457                memory: None,
458                dns: None,
459            }),
460            ..OpalConfig::default()
461        });
462
463        assert_eq!(
464            base.container_settings()
465                .and_then(|cfg| cfg.arch.as_deref()),
466            Some("arm64")
467        );
468    }
469
470    #[test]
471    fn job_override_for_merges_matching_entries() {
472        let config = OpalConfig {
473            jobs: vec![
474                JobOverrideConfig {
475                    name: "deploy".into(),
476                    arch: Some("arm64".into()),
477                    privileged: Some(false),
478                    cap_add: Vec::new(),
479                    cap_drop: Vec::new(),
480                },
481                JobOverrideConfig {
482                    name: "deploy".into(),
483                    arch: None,
484                    privileged: Some(true),
485                    cap_add: vec!["NET_ADMIN".into()],
486                    cap_drop: vec!["MKNOD".into()],
487                },
488            ],
489            ..OpalConfig::default()
490        };
491
492        let resolved = config.job_override_for("deploy").expect("override present");
493        assert_eq!(resolved.arch.as_deref(), Some("arm64"));
494        assert!(resolved.privileged);
495        assert_eq!(resolved.cap_add, vec!["NET_ADMIN"]);
496        assert_eq!(resolved.cap_drop, vec!["MKNOD"]);
497    }
498
499    #[test]
500    fn parses_default_engine_from_engine_table() {
501        let parsed: OpalConfig = toml::from_str(
502            r#"
503[engine]
504default = "docker"
505"#,
506        )
507        .expect("parse config");
508
509        assert_eq!(parsed.default_engine(), Some(crate::EngineChoice::Docker));
510    }
511
512    #[test]
513    fn project_level_engine_default_overrides_global() {
514        let mut base = OpalConfig::default();
515        base.merge(
516            toml::from_str(
517                r#"
518[engine]
519default = "docker"
520"#,
521            )
522            .expect("parse global config"),
523        );
524        base.merge(
525            toml::from_str(
526                r#"
527[engine]
528default = "container"
529"#,
530            )
531            .expect("parse project config"),
532        );
533
534        assert_eq!(base.default_engine(), Some(crate::EngineChoice::Container));
535    }
536
537    #[test]
538    fn parses_preserve_runtime_objects_from_engine_table() {
539        let parsed: OpalConfig = toml::from_str(
540            r#"
541[engine]
542preserve_runtime_objects = true
543"#,
544        )
545        .expect("parse config");
546
547        assert!(parsed.preserve_runtime_objects());
548    }
549
550    #[test]
551    fn ai_settings_default_to_ollama_friendly_values() {
552        let settings = OpalConfig::default();
553        assert_eq!(settings.ai.tail_lines, 200);
554        assert!(settings.ai.save_analysis);
555        assert_eq!(settings.ai.codex.command, "codex");
556        assert!(settings.ai.codex.model.is_none());
557        assert_eq!(settings.ai.ollama.host, "http://127.0.0.1:11434");
558        assert!(settings.ai.ollama.model.is_empty());
559    }
560
561    #[test]
562    fn ai_prompt_paths_resolve_relative_to_config_file_directory() {
563        let mut parsed: OpalConfig = toml::from_str(
564            r#"
565[ai.prompts]
566system_file = "prompts/ai/system.md"
567job_analysis_file = "prompts/ai/job-analysis.md"
568"#,
569        )
570        .expect("parse config");
571
572        parsed.resolve_relative_paths(Path::new("/tmp/project/.opal/config.toml"));
573
574        assert_eq!(
575            parsed.ai.prompts.system_file.as_deref(),
576            Some("/tmp/project/.opal/prompts/ai/system.md")
577        );
578        assert_eq!(
579            parsed.ai.prompts.job_analysis_file.as_deref(),
580            Some("/tmp/project/.opal/prompts/ai/job-analysis.md")
581        );
582    }
583}