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}