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::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_skipping_project_execution_trust() -> Result<Resolved> {
48 resolve_from_cwd_internal(true, false, None)
49}
50
51pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
55 resolve_from_cwd_internal(true, true, profile)
56}
57
58pub 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 if let Some(name) = profile {
108 apply_profile_patch(&mut cfg, name)?;
109 validate_config(&cfg)?;
110 }
111
112 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
139fn 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
167pub 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
174pub 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
180pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
182 validate_queue_file_override(cfg.queue.file.as_deref())?;
183
184 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
199pub 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 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
218pub 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
230pub fn project_config_path(repo_root: &Path) -> PathBuf {
232 let ralph_dir = repo_root.join(".ralph");
233 ralph_dir.join("config.jsonc")
234}
235
236pub 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}