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` with `.json` fallback.
17//! - Project config resolves from `.ralph/config.jsonc` with `.json` fallback.
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::util::validate_instruction_file_paths;
24use anyhow::{Context, Result, bail};
25use std::env;
26use std::path::{Path, PathBuf};
27
28use super::Resolved;
29use super::layer::{apply_layer, load_layer};
30use super::validation::{
31    validate_config, validate_queue_done_file_override, validate_queue_file_override,
32    validate_queue_id_prefix_override, validate_queue_id_width_override,
33};
34
35/// Resolve configuration from the current working directory.
36pub fn resolve_from_cwd() -> Result<Resolved> {
37    resolve_from_cwd_internal(true, None)
38}
39
40/// Resolve config with an optional profile selection.
41///
42/// The profile is applied after base config resolution but before instruction_files validation.
43pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
44    resolve_from_cwd_internal(true, profile)
45}
46
47/// Resolve config for the doctor command, skipping instruction_files validation.
48/// This allows doctor to diagnose and warn about missing files without failing early.
49pub fn resolve_from_cwd_for_doctor() -> Result<Resolved> {
50    resolve_from_cwd_internal(false, None)
51}
52
53fn resolve_from_cwd_internal(
54    validate_instruction_files: bool,
55    profile: Option<&str>,
56) -> Result<Resolved> {
57    let cwd = env::current_dir().context("resolve current working directory")?;
58    log::debug!("resolving configuration from cwd: {}", cwd.display());
59    let repo_root = find_repo_root(&cwd);
60
61    let global_path = global_config_path();
62    let project_path = project_config_path(&repo_root);
63
64    let mut cfg = Config::default();
65
66    if let Some(path) = global_path.as_ref() {
67        log::debug!("checking global config at: {}", path.display());
68        if path.exists() {
69            log::debug!("loading global config: {}", path.display());
70            let layer = load_layer(path)
71                .with_context(|| format!("load global config {}", path.display()))?;
72            cfg = apply_layer(cfg, layer)
73                .with_context(|| format!("apply global config {}", path.display()))?;
74        }
75    }
76
77    log::debug!("checking project config at: {}", project_path.display());
78    if project_path.exists() {
79        log::debug!("loading project config: {}", project_path.display());
80        let layer = load_layer(&project_path)
81            .with_context(|| format!("load project config {}", project_path.display()))?;
82        cfg = apply_layer(cfg, layer)
83            .with_context(|| format!("apply project config {}", project_path.display()))?;
84    }
85
86    validate_config(&cfg)?;
87
88    // Apply selected profile if specified
89    if let Some(name) = profile {
90        apply_profile_patch(&mut cfg, name)?;
91    }
92
93    // Validate instruction_files early for fast feedback (before runtime prompt rendering)
94    if validate_instruction_files {
95        validate_instruction_file_paths(&repo_root, &cfg)
96            .with_context(|| "validate instruction_files from config")?;
97    }
98
99    let id_prefix = resolve_id_prefix(&cfg)?;
100    let id_width = resolve_id_width(&cfg)?;
101    let queue_path = resolve_queue_path(&repo_root, &cfg)?;
102    let done_path = resolve_done_path(&repo_root, &cfg)?;
103
104    log::debug!("resolved repo_root: {}", repo_root.display());
105    log::debug!("resolved queue_path: {}", queue_path.display());
106    log::debug!("resolved done_path: {}", done_path.display());
107
108    Ok(Resolved {
109        config: cfg,
110        repo_root,
111        queue_path,
112        done_path,
113        id_prefix,
114        id_width,
115        global_config_path: global_path,
116        project_config_path: Some(project_path),
117    })
118}
119
120/// Apply a named profile patch to the resolved config.
121///
122/// Profile values are merged into `cfg.agent` using leaf-wise merge semantics.
123/// Config-defined profiles take precedence over built-in profiles.
124fn apply_profile_patch(cfg: &mut Config, name: &str) -> Result<()> {
125    let name = name.trim();
126    if name.is_empty() {
127        bail!("Invalid --profile: name cannot be empty");
128    }
129
130    let patch =
131        crate::agent::resolve_profile_patch(name, cfg.profiles.as_ref()).ok_or_else(|| {
132            let names = crate::agent::all_profile_names(cfg.profiles.as_ref());
133            anyhow::anyhow!(
134                "Unknown profile: {name:?}. Available profiles: {}",
135                names.into_iter().collect::<Vec<_>>().join(", ")
136            )
137        })?;
138
139    cfg.agent.merge_from(patch);
140    Ok(())
141}
142
143/// Resolve a JSON path with .json fallback.
144///
145/// Checks if the .jsonc path exists; if not, checks for .json variant.
146/// Returns the original path if neither exists (to preserve error messages).
147pub fn prefer_jsonc_then_json(base_path: PathBuf) -> PathBuf {
148    // Check .jsonc FIRST (new default)
149    let jsonc_path = base_path.with_extension("jsonc");
150    if jsonc_path.is_file() {
151        return jsonc_path;
152    }
153    // Fall back to .json (legacy support)
154    // When base_path is .jsonc, also check the .json variant
155    let json_path = base_path.with_extension("json");
156    if json_path.is_file() {
157        return json_path;
158    }
159    // Return base_path if neither exists (for error messages)
160    base_path
161}
162
163/// Resolve the queue ID prefix from config.
164pub fn resolve_id_prefix(cfg: &Config) -> Result<String> {
165    validate_queue_id_prefix_override(cfg.queue.id_prefix.as_deref())?;
166    let raw = cfg.queue.id_prefix.as_deref().unwrap_or(DEFAULT_ID_PREFIX);
167    Ok(raw.trim().to_uppercase())
168}
169
170/// Resolve the queue ID width from config.
171pub fn resolve_id_width(cfg: &Config) -> Result<usize> {
172    validate_queue_id_width_override(cfg.queue.id_width)?;
173    Ok(cfg.queue.id_width.unwrap_or(DEFAULT_ID_WIDTH as u8) as usize)
174}
175
176/// Resolve the queue file path from config.
177pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
178    validate_queue_file_override(cfg.queue.file.as_deref())?;
179
180    // Get the raw path, using default if not specified
181    let raw = cfg
182        .queue
183        .file
184        .clone()
185        .unwrap_or_else(|| PathBuf::from(DEFAULT_QUEUE_FILE));
186
187    // Check if this is the default path (we'll apply .jsonc fallback to defaults)
188    let is_default = raw.as_os_str() == DEFAULT_QUEUE_FILE;
189
190    let value = fsutil::expand_tilde(&raw);
191    let resolved = if value.is_absolute() {
192        value
193    } else {
194        repo_root.join(value)
195    };
196
197    if is_default {
198        // For default path, check .jsonc first, then fall back to .json
199        Ok(prefer_jsonc_then_json(resolved))
200    } else {
201        // For explicit user overrides, use the path as-is
202        Ok(resolved)
203    }
204}
205
206/// Resolve the done file path from config.
207pub fn resolve_done_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
208    validate_queue_done_file_override(cfg.queue.done_file.as_deref())?;
209
210    // Get the raw path, using default if not specified
211    let raw = cfg
212        .queue
213        .done_file
214        .clone()
215        .unwrap_or_else(|| PathBuf::from(DEFAULT_DONE_FILE));
216
217    // Check if this is the default path (we'll apply .jsonc fallback to defaults)
218    let is_default = raw.as_os_str() == DEFAULT_DONE_FILE;
219
220    let value = fsutil::expand_tilde(&raw);
221    let resolved = if value.is_absolute() {
222        value
223    } else {
224        repo_root.join(value)
225    };
226
227    if is_default {
228        // For default path, check .jsonc first, then fall back to .json
229        Ok(prefer_jsonc_then_json(resolved))
230    } else {
231        // For explicit user overrides, use the path as-is
232        Ok(resolved)
233    }
234}
235
236/// Get the path to the global config file.
237pub fn global_config_path() -> Option<PathBuf> {
238    let base = if let Some(value) = env::var_os("XDG_CONFIG_HOME") {
239        PathBuf::from(value)
240    } else {
241        let home = env::var_os("HOME")?;
242        PathBuf::from(home).join(".config")
243    };
244    let ralph_dir = base.join("ralph");
245    Some(prefer_jsonc_then_json(ralph_dir.join("config.jsonc")))
246}
247
248/// Get the path to the project config file for a given repo root.
249pub fn project_config_path(repo_root: &Path) -> PathBuf {
250    let ralph_dir = repo_root.join(".ralph");
251    prefer_jsonc_then_json(ralph_dir.join("config.jsonc"))
252}
253
254/// Find the repository root starting from a given path.
255///
256/// Searches upward for a `.ralph/` directory with marker files
257/// or a `.git/` directory.
258pub fn find_repo_root(start: &Path) -> PathBuf {
259    log::debug!("searching for repo root starting from: {}", start.display());
260    for dir in start.ancestors() {
261        log::debug!("checking directory: {}", dir.display());
262        let ralph_dir = dir.join(".ralph");
263        if ralph_dir.is_dir() {
264            let has_ralph_marker = ["queue.json", "queue.jsonc", "config.json", "config.jsonc"]
265                .iter()
266                .any(|name| ralph_dir.join(name).is_file());
267            if has_ralph_marker {
268                log::debug!("found repo root at: {} (via .ralph/)", dir.display());
269                return dir.to_path_buf();
270            }
271        }
272        if dir.join(".git").exists() {
273            log::debug!("found repo root at: {} (via .git/)", dir.display());
274            return dir.to_path_buf();
275        }
276    }
277    log::debug!(
278        "no repo root found, using start directory: {}",
279        start.display()
280    );
281    start.to_path_buf()
282}