ralph/config/
resolution.rs1use 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::{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
37pub fn resolve_from_cwd() -> Result<Resolved> {
39 resolve_from_cwd_internal(true, true, None)
40}
41
42pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
46 resolve_from_cwd_internal(true, true, profile)
47}
48
49pub fn resolve_from_cwd_for_doctor() -> Result<Resolved> {
52 resolve_from_cwd_internal(false, false, None)
53}
54
55fn resolve_from_cwd_internal(
56 validate_instruction_files: bool,
57 validate_execution_trust: bool,
58 profile: Option<&str>,
59) -> Result<Resolved> {
60 let cwd = env::current_dir().context("resolve current working directory")?;
61 log::debug!("resolving configuration from cwd: {}", cwd.display());
62 let repo_root = find_repo_root(&cwd);
63
64 let global_path = global_config_path();
65 let project_path = project_config_path(&repo_root);
66 let repo_trust = load_repo_trust(&repo_root)?;
67
68 let mut cfg = Config::default();
69 let mut project_layer: Option<ConfigLayer> = None;
70
71 if let Some(path) = global_path.as_ref() {
72 log::debug!("checking global config at: {}", path.display());
73 if path.exists() {
74 log::debug!("loading global config: {}", path.display());
75 let layer = load_layer(path)
76 .with_context(|| format!("load global config {}", path.display()))?;
77 cfg = apply_layer(cfg, layer)
78 .with_context(|| format!("apply global config {}", path.display()))?;
79 }
80 }
81
82 log::debug!("checking project config at: {}", project_path.display());
83 if project_path.exists() {
84 log::debug!("loading project config: {}", project_path.display());
85 let layer = load_layer(&project_path)
86 .with_context(|| format!("load project config {}", project_path.display()))?;
87 project_layer = Some(layer.clone());
88 cfg = apply_layer(cfg, layer)
89 .with_context(|| format!("apply project config {}", project_path.display()))?;
90 }
91
92 if validate_execution_trust {
93 validate_project_execution_trust(project_layer.as_ref(), &repo_trust)?;
94 }
95 validate_config(&cfg)?;
96
97 if let Some(name) = profile {
99 apply_profile_patch(&mut cfg, name)?;
100 validate_config(&cfg)?;
101 }
102
103 if validate_instruction_files {
105 validate_instruction_file_paths(&repo_root, &cfg)
106 .with_context(|| "validate instruction_files from config")?;
107 }
108
109 let id_prefix = resolve_id_prefix(&cfg)?;
110 let id_width = resolve_id_width(&cfg)?;
111 let queue_path = resolve_queue_path(&repo_root, &cfg)?;
112 let done_path = resolve_done_path(&repo_root, &cfg)?;
113
114 log::debug!("resolved repo_root: {}", repo_root.display());
115 log::debug!("resolved queue_path: {}", queue_path.display());
116 log::debug!("resolved done_path: {}", done_path.display());
117
118 Ok(Resolved {
119 config: cfg,
120 repo_root,
121 queue_path,
122 done_path,
123 id_prefix,
124 id_width,
125 global_config_path: global_path,
126 project_config_path: Some(project_path),
127 })
128}
129
130fn apply_profile_patch(cfg: &mut Config, name: &str) -> Result<()> {
135 let name = name.trim();
136 if name.is_empty() {
137 bail!("Invalid --profile: name cannot be empty");
138 }
139
140 let patch =
141 crate::agent::resolve_profile_patch(name, cfg.profiles.as_ref()).ok_or_else(|| {
142 let names = crate::agent::all_profile_names(cfg.profiles.as_ref());
143 anyhow::anyhow!(
144 "Unknown profile: {name:?}. Available profiles: {}",
145 names.into_iter().collect::<Vec<_>>().join(", ")
146 )
147 })?;
148
149 cfg.agent.merge_from(patch);
150 Ok(())
151}
152
153pub fn prefer_jsonc_then_json(base_path: PathBuf) -> PathBuf {
158 let jsonc_path = base_path.with_extension("jsonc");
160 if jsonc_path.is_file() {
161 return jsonc_path;
162 }
163 let json_path = base_path.with_extension("json");
166 if json_path.is_file() {
167 return json_path;
168 }
169 base_path
171}
172
173pub fn resolve_id_prefix(cfg: &Config) -> Result<String> {
175 validate_queue_id_prefix_override(cfg.queue.id_prefix.as_deref())?;
176 let raw = cfg.queue.id_prefix.as_deref().unwrap_or(DEFAULT_ID_PREFIX);
177 Ok(raw.trim().to_uppercase())
178}
179
180pub fn resolve_id_width(cfg: &Config) -> Result<usize> {
182 validate_queue_id_width_override(cfg.queue.id_width)?;
183 Ok(cfg.queue.id_width.unwrap_or(DEFAULT_ID_WIDTH as u8) as usize)
184}
185
186pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
188 validate_queue_file_override(cfg.queue.file.as_deref())?;
189
190 let raw = cfg
192 .queue
193 .file
194 .clone()
195 .unwrap_or_else(|| PathBuf::from(DEFAULT_QUEUE_FILE));
196
197 let is_default = raw.as_os_str() == DEFAULT_QUEUE_FILE;
199
200 let value = fsutil::expand_tilde(&raw);
201 let resolved = if value.is_absolute() {
202 value
203 } else {
204 repo_root.join(value)
205 };
206
207 if is_default {
208 Ok(prefer_jsonc_then_json(resolved))
210 } else {
211 Ok(resolved)
213 }
214}
215
216pub fn resolve_done_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
218 validate_queue_done_file_override(cfg.queue.done_file.as_deref())?;
219
220 let raw = cfg
222 .queue
223 .done_file
224 .clone()
225 .unwrap_or_else(|| PathBuf::from(DEFAULT_DONE_FILE));
226
227 let is_default = raw.as_os_str() == DEFAULT_DONE_FILE;
229
230 let value = fsutil::expand_tilde(&raw);
231 let resolved = if value.is_absolute() {
232 value
233 } else {
234 repo_root.join(value)
235 };
236
237 if is_default {
238 Ok(prefer_jsonc_then_json(resolved))
240 } else {
241 Ok(resolved)
243 }
244}
245
246pub fn global_config_path() -> Option<PathBuf> {
248 let base = if let Some(value) = env::var_os("XDG_CONFIG_HOME") {
249 PathBuf::from(value)
250 } else {
251 let home = env::var_os("HOME")?;
252 PathBuf::from(home).join(".config")
253 };
254 let ralph_dir = base.join("ralph");
255 Some(prefer_jsonc_then_json(ralph_dir.join("config.jsonc")))
256}
257
258pub fn project_config_path(repo_root: &Path) -> PathBuf {
260 let ralph_dir = repo_root.join(".ralph");
261 prefer_jsonc_then_json(ralph_dir.join("config.jsonc"))
262}
263
264pub fn find_repo_root(start: &Path) -> PathBuf {
269 log::debug!("searching for repo root starting from: {}", start.display());
270 for dir in start.ancestors() {
271 log::debug!("checking directory: {}", dir.display());
272 let ralph_dir = dir.join(".ralph");
273 if ralph_dir.is_dir() {
274 let has_ralph_marker = ["queue.json", "queue.jsonc", "config.json", "config.jsonc"]
275 .iter()
276 .any(|name| ralph_dir.join(name).is_file());
277 if has_ralph_marker {
278 log::debug!("found repo root at: {} (via .ralph/)", dir.display());
279 return dir.to_path_buf();
280 }
281 }
282 if dir.join(".git").exists() {
283 log::debug!("found repo root at: {} (via .git/)", dir.display());
284 return dir.to_path_buf();
285 }
286 }
287 log::debug!(
288 "no repo root found, using start directory: {}",
289 start.display()
290 );
291 start.to_path_buf()
292}