Skip to main content

ralph/config/
resolution.rs

1//! Configuration resolution for Ralph.
2//!
3//! Responsibilities:
4//! - Resolve configuration from multiple layers: global, project, and defaults.
5//! - Discover repository root via `.ralph/` directory or `.git/`.
6//! - Resolve queue/done file paths and ID generation settings.
7//! - Apply profile patches after base config resolution.
8//!
9//! Not handled here:
10//! - Config file loading/parsing (see `super::layer`).
11//! - Config validation (see `super::validation`).
12//!
13//! Invariants/assumptions:
14//! - Config layers are applied in order: defaults, global, project (later overrides earlier).
15//! - Paths are resolved relative to repo root unless absolute.
16//! - Global config resolves from `~/.config/ralph/config.jsonc`.
17//! - Project config resolves from `.ralph/config.jsonc`.
18
19use crate::constants::defaults::DEFAULT_ID_WIDTH;
20use crate::constants::queue::{DEFAULT_DONE_FILE, DEFAULT_ID_PREFIX, DEFAULT_QUEUE_FILE};
21use crate::contracts::Config;
22use crate::fsutil;
23use crate::prompts_internal::validate_instruction_file_paths;
24use anyhow::{Context, Result, bail};
25use std::env;
26use std::path::{Path, PathBuf};
27
28use super::Resolved;
29use super::layer::{ConfigLayer, apply_layer, load_layer};
30use super::trust::load_repo_trust;
31use super::validation::{
32    validate_config, validate_project_execution_trust, validate_queue_done_file_override,
33    validate_queue_file_override, validate_queue_id_prefix_override,
34    validate_queue_id_width_override,
35};
36
37/// Resolve configuration from the current working directory.
38pub fn resolve_from_cwd() -> Result<Resolved> {
39    resolve_from_cwd_internal(true, true, None)
40}
41
42/// Resolve like `resolve_from_cwd`, but skip project-layer execution trust validation.
43///
44/// Used when the operator is explicitly opting into trust (for example `ralph init
45/// --trust-project-commands`) so initialization can proceed before `.ralph/trust.jsonc`
46/// exists, then the trust file is written afterward.
47pub fn resolve_from_cwd_skipping_project_execution_trust() -> Result<Resolved> {
48    resolve_from_cwd_internal(true, false, None)
49}
50
51/// Resolve config with an optional profile selection.
52///
53/// The profile is applied after base config resolution but before instruction_files validation.
54pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
55    resolve_from_cwd_internal(true, true, profile)
56}
57
58/// Resolve config for the doctor command, skipping instruction_files validation.
59/// This allows doctor to diagnose and warn about missing files without failing early.
60pub fn resolve_from_cwd_for_doctor() -> Result<Resolved> {
61    resolve_from_cwd_internal(false, false, None)
62}
63
64fn resolve_from_cwd_internal(
65    validate_instruction_files: bool,
66    validate_execution_trust: bool,
67    profile: Option<&str>,
68) -> Result<Resolved> {
69    let cwd = env::current_dir().context("resolve current working directory")?;
70    log::debug!("resolving configuration from cwd: {}", cwd.display());
71    let repo_root = find_repo_root(&cwd);
72
73    let global_path = global_config_path();
74    let project_path = project_config_path(&repo_root);
75    let repo_trust = load_repo_trust(&repo_root)?;
76
77    let mut cfg = Config::default();
78    let mut project_layer: Option<ConfigLayer> = None;
79
80    if let Some(path) = global_path.as_ref() {
81        log::debug!("checking global config at: {}", path.display());
82        if path.exists() {
83            log::debug!("loading global config: {}", path.display());
84            let layer = load_layer(path)
85                .with_context(|| format!("load global config {}", path.display()))?;
86            cfg = apply_layer(cfg, layer)
87                .with_context(|| format!("apply global config {}", path.display()))?;
88        }
89    }
90
91    log::debug!("checking project config at: {}", project_path.display());
92    if project_path.exists() {
93        log::debug!("loading project config: {}", project_path.display());
94        let layer = load_layer(&project_path)
95            .with_context(|| format!("load project config {}", project_path.display()))?;
96        project_layer = Some(layer.clone());
97        cfg = apply_layer(cfg, layer)
98            .with_context(|| format!("apply project config {}", project_path.display()))?;
99    }
100
101    if validate_execution_trust {
102        validate_project_execution_trust(project_layer.as_ref(), &repo_trust)?;
103    }
104    validate_config(&cfg)?;
105
106    // Apply selected profile if specified
107    if let Some(name) = profile {
108        apply_profile_patch(&mut cfg, name)?;
109        validate_config(&cfg)?;
110    }
111
112    // Validate instruction_files early for fast feedback (before runtime prompt rendering)
113    if validate_instruction_files {
114        validate_instruction_file_paths(&repo_root, &cfg)
115            .with_context(|| "validate instruction_files from config")?;
116    }
117
118    let id_prefix = resolve_id_prefix(&cfg)?;
119    let id_width = resolve_id_width(&cfg)?;
120    let queue_path = resolve_queue_path(&repo_root, &cfg)?;
121    let done_path = resolve_done_path(&repo_root, &cfg)?;
122
123    log::debug!("resolved repo_root: {}", repo_root.display());
124    log::debug!("resolved queue_path: {}", queue_path.display());
125    log::debug!("resolved done_path: {}", done_path.display());
126
127    Ok(Resolved {
128        config: cfg,
129        repo_root,
130        queue_path,
131        done_path,
132        id_prefix,
133        id_width,
134        global_config_path: global_path,
135        project_config_path: Some(project_path),
136    })
137}
138
139/// Apply a named profile patch to the resolved config.
140///
141/// Profile values are merged into `cfg.agent` using leaf-wise merge semantics.
142fn apply_profile_patch(cfg: &mut Config, name: &str) -> Result<()> {
143    let name = name.trim();
144    if name.is_empty() {
145        bail!("Invalid --profile: name cannot be empty");
146    }
147
148    let patch =
149        crate::agent::resolve_profile_patch(name, cfg.profiles.as_ref()).ok_or_else(|| {
150            let names = crate::agent::all_profile_names(cfg.profiles.as_ref());
151            if names.is_empty() {
152                anyhow::anyhow!(
153                    "Unknown profile: {name:?}. No profiles are configured. Define profiles under the `profiles` key in .ralph/config.jsonc or ~/.config/ralph/config.jsonc."
154                )
155            } else {
156                anyhow::anyhow!(
157                    "Unknown profile: {name:?}. Available configured profiles: {}",
158                    names.into_iter().collect::<Vec<_>>().join(", ")
159                )
160            }
161        })?;
162
163    cfg.agent.merge_from(patch);
164    Ok(())
165}
166
167/// Resolve the queue ID prefix from config.
168pub fn resolve_id_prefix(cfg: &Config) -> Result<String> {
169    validate_queue_id_prefix_override(cfg.queue.id_prefix.as_deref())?;
170    let raw = cfg.queue.id_prefix.as_deref().unwrap_or(DEFAULT_ID_PREFIX);
171    Ok(raw.trim().to_uppercase())
172}
173
174/// Resolve the queue ID width from config.
175pub fn resolve_id_width(cfg: &Config) -> Result<usize> {
176    validate_queue_id_width_override(cfg.queue.id_width)?;
177    Ok(cfg.queue.id_width.unwrap_or(DEFAULT_ID_WIDTH as u8) as usize)
178}
179
180/// Resolve the queue file path from config.
181pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
182    validate_queue_file_override(cfg.queue.file.as_deref())?;
183
184    // Get the raw path, using default if not specified
185    let raw = cfg
186        .queue
187        .file
188        .clone()
189        .unwrap_or_else(|| PathBuf::from(DEFAULT_QUEUE_FILE));
190
191    let value = fsutil::expand_tilde(&raw);
192    Ok(if value.is_absolute() {
193        value
194    } else {
195        repo_root.join(value)
196    })
197}
198
199/// Resolve the done file path from config.
200pub fn resolve_done_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
201    validate_queue_done_file_override(cfg.queue.done_file.as_deref())?;
202
203    // Get the raw path, using default if not specified
204    let raw = cfg
205        .queue
206        .done_file
207        .clone()
208        .unwrap_or_else(|| PathBuf::from(DEFAULT_DONE_FILE));
209
210    let value = fsutil::expand_tilde(&raw);
211    Ok(if value.is_absolute() {
212        value
213    } else {
214        repo_root.join(value)
215    })
216}
217
218/// Get the path to the global config file.
219pub fn global_config_path() -> Option<PathBuf> {
220    let base = if let Some(value) = env::var_os("XDG_CONFIG_HOME") {
221        PathBuf::from(value)
222    } else {
223        let home = env::var_os("HOME")?;
224        PathBuf::from(home).join(".config")
225    };
226    let ralph_dir = base.join("ralph");
227    Some(ralph_dir.join("config.jsonc"))
228}
229
230/// Get the path to the project config file for a given repo root.
231pub fn project_config_path(repo_root: &Path) -> PathBuf {
232    let ralph_dir = repo_root.join(".ralph");
233    ralph_dir.join("config.jsonc")
234}
235
236/// Find the repository root starting from a given path.
237///
238/// Searches upward for a `.ralph/` directory with marker files
239/// or a `.git/` directory.
240pub fn find_repo_root(start: &Path) -> PathBuf {
241    log::debug!("searching for repo root starting from: {}", start.display());
242    for dir in start.ancestors() {
243        log::debug!("checking directory: {}", dir.display());
244        let ralph_dir = dir.join(".ralph");
245        if ralph_dir.is_dir() {
246            let has_ralph_marker = ["queue.jsonc", "config.jsonc", "done.jsonc"]
247                .iter()
248                .any(|name| ralph_dir.join(name).is_file());
249            if has_ralph_marker {
250                log::debug!("found repo root at: {} (via .ralph/)", dir.display());
251                return dir.to_path_buf();
252            }
253        }
254        if dir.join(".git").exists() {
255            log::debug!("found repo root at: {} (via .git/)", dir.display());
256            return dir.to_path_buf();
257        }
258    }
259    log::debug!(
260        "no repo root found, using start directory: {}",
261        start.display()
262    );
263    start.to_path_buf()
264}