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::{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
35pub fn resolve_from_cwd() -> Result<Resolved> {
37 resolve_from_cwd_internal(true, None)
38}
39
40pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
44 resolve_from_cwd_internal(true, profile)
45}
46
47pub 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 if let Some(name) = profile {
90 apply_profile_patch(&mut cfg, name)?;
91 }
92
93 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
120fn 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
143pub fn prefer_jsonc_then_json(base_path: PathBuf) -> PathBuf {
148 let jsonc_path = base_path.with_extension("jsonc");
150 if jsonc_path.is_file() {
151 return jsonc_path;
152 }
153 let json_path = base_path.with_extension("json");
156 if json_path.is_file() {
157 return json_path;
158 }
159 base_path
161}
162
163pub 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
170pub 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
176pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
178 validate_queue_file_override(cfg.queue.file.as_deref())?;
179
180 let raw = cfg
182 .queue
183 .file
184 .clone()
185 .unwrap_or_else(|| PathBuf::from(DEFAULT_QUEUE_FILE));
186
187 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 Ok(prefer_jsonc_then_json(resolved))
200 } else {
201 Ok(resolved)
203 }
204}
205
206pub 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 let raw = cfg
212 .queue
213 .done_file
214 .clone()
215 .unwrap_or_else(|| PathBuf::from(DEFAULT_DONE_FILE));
216
217 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 Ok(prefer_jsonc_then_json(resolved))
230 } else {
231 Ok(resolved)
233 }
234}
235
236pub 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
248pub 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
254pub 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}