Skip to main content

ralph/config/
layer.rs

1//! Configuration layer handling for Ralph.
2//!
3//! Responsibilities:
4//! - Define `ConfigLayer` for partial config from JSON files.
5//! - Load config layers with JSONC comment support.
6//! - Save config layers with automatic directory creation.
7//! - Apply/merge layers into base configuration.
8//!
9//! Not handled here:
10//! - Config validation (see `super::validation`).
11//! - Path resolution (see `super::resolution`).
12//! - Profile application (see `super::resolution`).
13//!
14//! Invariants/assumptions:
15//! - `save_layer` creates parent directories automatically if needed.
16//! - `apply_layer` merges using leaf-wise semantics for nested structures.
17//! - Version must be 1; unsupported versions are rejected during apply.
18
19use crate::contracts::{
20    AgentConfig, Config, LoopConfig, ParallelConfig, PluginsConfig, ProjectType, QueueConfig,
21};
22use crate::fsutil;
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::fs;
27use std::path::Path;
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[serde(default, deny_unknown_fields)]
31pub struct ConfigLayer {
32    pub version: Option<u32>,
33    pub project_type: Option<ProjectType>,
34    pub queue: QueueConfig,
35    pub agent: AgentConfig,
36    pub parallel: ParallelConfig,
37    #[serde(rename = "loop")]
38    pub loop_field: LoopConfig,
39    pub plugins: PluginsConfig,
40    /// Named profiles for quick workflow switching.
41    pub profiles: Option<BTreeMap<String, AgentConfig>>,
42}
43
44/// Load a config layer from a JSON/JSONC file.
45pub fn load_layer(path: &Path) -> Result<ConfigLayer> {
46    let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
47    let layer =
48        crate::jsonc::parse_jsonc::<ConfigLayer>(&raw, &format!("config {}", path.display()))?;
49    Ok(layer)
50}
51
52/// Save a config layer to a JSON file.
53/// Automatically sets version to 1 if not specified and creates parent directories.
54pub fn save_layer(path: &Path, layer: &ConfigLayer) -> Result<()> {
55    let mut to_save = layer.clone();
56    if to_save.version.is_none() {
57        to_save.version = Some(1);
58    }
59    if let Some(parent) = path.parent() {
60        fs::create_dir_all(parent)
61            .with_context(|| format!("create config directory {}", parent.display()))?;
62    }
63    let rendered = serde_json::to_string_pretty(&to_save).context("serialize config JSON")?;
64    fsutil::write_atomic(path, rendered.as_bytes())
65        .with_context(|| format!("write config JSON {}", path.display()))?;
66    Ok(())
67}
68
69/// Apply a config layer on top of a base config.
70/// Later layers override earlier ones using leaf-wise merge semantics.
71pub fn apply_layer(mut base: Config, layer: ConfigLayer) -> Result<Config> {
72    if let Some(version) = layer.version {
73        if version != 1 {
74            bail!(
75                "Unsupported config version: {}. Ralph requires version 1. Update the 'version' field in your config file.",
76                version
77            );
78        }
79        base.version = version;
80    }
81
82    if let Some(project_type) = layer.project_type {
83        base.project_type = Some(project_type);
84    }
85
86    base.queue.merge_from(layer.queue);
87    base.agent.merge_from(layer.agent);
88    base.parallel.merge_from(layer.parallel);
89    base.loop_field.merge_from(layer.loop_field);
90    base.plugins.merge_from(layer.plugins);
91
92    // Merge profiles across layers
93    if let Some(profiles) = layer.profiles {
94        let base_profiles = base.profiles.get_or_insert_with(BTreeMap::new);
95        for (name, patch) in profiles {
96            base_profiles
97                .entry(name)
98                .and_modify(|existing| existing.merge_from(patch.clone()))
99                .or_insert(patch);
100        }
101    }
102
103    Ok(base)
104}