1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::config::load_configs_from_roots;
5use crate::env::ResolvedRoots;
6use crate::model::{
7 ConfigDocumentEntry, ConfigLoadError, ConfigScopeFile, Context, DocumentSource, DocumentStatus,
8 FallbackMode, LoadedConfigs, ResolveReport, ResolveSummary, ResolvedDocument,
9 SUPPORTED_CONTEXTS, Scope,
10};
11use crate::paths::normalize_path;
12
13pub fn supported_contexts() -> &'static [Context] {
14 &SUPPORTED_CONTEXTS
15}
16
17pub fn resolve(
18 context: Context,
19 roots: &ResolvedRoots,
20 strict: bool,
21) -> Result<ResolveReport, ConfigLoadError> {
22 resolve_with_mode(context, roots, strict, FallbackMode::Auto)
23}
24
25pub fn resolve_with_mode(
26 context: Context,
27 roots: &ResolvedRoots,
28 strict: bool,
29 fallback_mode: FallbackMode,
30) -> Result<ResolveReport, ConfigLoadError> {
31 let configs = load_configs_from_roots(roots)?;
32 Ok(resolve_with_configs_with_mode(
33 context,
34 roots,
35 strict,
36 fallback_mode,
37 &configs,
38 ))
39}
40
41pub fn resolve_builtin(context: Context, roots: &ResolvedRoots, strict: bool) -> ResolveReport {
42 resolve_builtin_with_mode(context, roots, strict, FallbackMode::Auto)
43}
44
45pub fn resolve_builtin_with_mode(
46 context: Context,
47 roots: &ResolvedRoots,
48 strict: bool,
49 fallback_mode: FallbackMode,
50) -> ResolveReport {
51 match resolve_with_mode(context, roots, strict, fallback_mode) {
52 Ok(report) => report,
53 Err(_) => resolve_builtin_only_with_mode(context, roots, strict, fallback_mode),
54 }
55}
56
57pub fn resolve_builtin_only(
58 context: Context,
59 roots: &ResolvedRoots,
60 strict: bool,
61) -> ResolveReport {
62 resolve_builtin_only_with_mode(context, roots, strict, FallbackMode::Auto)
63}
64
65pub fn resolve_builtin_only_with_mode(
66 context: Context,
67 roots: &ResolvedRoots,
68 strict: bool,
69 fallback_mode: FallbackMode,
70) -> ResolveReport {
71 let project_fallback_root = project_fallback_root(roots, fallback_mode);
72 let documents = match context {
73 Context::Startup => resolve_startup(roots, fallback_mode),
74 Context::SkillDev => vec![resolve_required_doc(
75 Context::SkillDev,
76 Scope::Home,
77 &roots.codex_home,
78 "DEVELOPMENT.md",
79 "skill development guidance from CODEX_HOME/DEVELOPMENT.md",
80 DocumentSource::Builtin,
81 )],
82 Context::TaskTools => vec![resolve_required_doc(
83 Context::TaskTools,
84 Scope::Home,
85 &roots.codex_home,
86 "CLI_TOOLS.md",
87 "tool-selection guidance from CODEX_HOME/CLI_TOOLS.md",
88 DocumentSource::Builtin,
89 )],
90 Context::ProjectDev => vec![resolve_required_doc_with_project_fallback(
91 Context::ProjectDev,
92 Scope::Project,
93 &roots.project_path,
94 "DEVELOPMENT.md",
95 "project development guidance from PROJECT_PATH/DEVELOPMENT.md",
96 DocumentSource::Builtin,
97 project_fallback_root,
98 )],
99 };
100
101 let summary = ResolveSummary::from_documents(&documents);
102
103 ResolveReport {
104 context,
105 strict,
106 codex_home: roots.codex_home.clone(),
107 project_path: roots.project_path.clone(),
108 is_linked_worktree: roots.is_linked_worktree,
109 git_common_dir: roots.git_common_dir.clone(),
110 primary_worktree_path: roots.primary_worktree_path.clone(),
111 documents,
112 summary,
113 }
114}
115
116pub fn resolve_with_configs(
117 context: Context,
118 roots: &ResolvedRoots,
119 strict: bool,
120 configs: &LoadedConfigs,
121) -> ResolveReport {
122 resolve_with_configs_with_mode(context, roots, strict, FallbackMode::Auto, configs)
123}
124
125pub fn resolve_with_configs_with_mode(
126 context: Context,
127 roots: &ResolvedRoots,
128 strict: bool,
129 fallback_mode: FallbackMode,
130 configs: &LoadedConfigs,
131) -> ResolveReport {
132 let mut documents =
133 resolve_builtin_only_with_mode(context, roots, strict, fallback_mode).documents;
134 let builtin_keys: HashSet<ResolveKey> =
135 documents.iter().map(ResolveKey::from_document).collect();
136
137 let mut extension_documents: Vec<ResolvedDocument> = Vec::new();
138 let mut extension_indices: HashMap<ResolveKey, usize> = HashMap::new();
139
140 for config in configs.in_load_order() {
141 merge_extension_documents(
142 context,
143 roots,
144 fallback_mode,
145 config,
146 &builtin_keys,
147 &mut extension_documents,
148 &mut extension_indices,
149 );
150 }
151
152 documents.extend(extension_documents);
153 let summary = ResolveSummary::from_documents(&documents);
154
155 ResolveReport {
156 context,
157 strict,
158 codex_home: roots.codex_home.clone(),
159 project_path: roots.project_path.clone(),
160 is_linked_worktree: roots.is_linked_worktree,
161 git_common_dir: roots.git_common_dir.clone(),
162 primary_worktree_path: roots.primary_worktree_path.clone(),
163 documents,
164 summary,
165 }
166}
167
168fn merge_extension_documents(
169 context: Context,
170 roots: &ResolvedRoots,
171 fallback_mode: FallbackMode,
172 config: &ConfigScopeFile,
173 builtin_keys: &HashSet<ResolveKey>,
174 extension_documents: &mut Vec<ResolvedDocument>,
175 extension_indices: &mut HashMap<ResolveKey, usize>,
176) {
177 for (index, entry) in config.documents.iter().enumerate() {
178 if entry.context != context {
179 continue;
180 }
181
182 let resolved_path =
183 resolve_extension_path_with_project_fallback(entry, roots, fallback_mode);
184 let key = ResolveKey::new(context, entry.scope, resolved_path.clone());
185 if builtin_keys.contains(&key) {
186 continue;
187 }
188
189 let document = ResolvedDocument {
190 context,
191 scope: entry.scope,
192 path: resolved_path.clone(),
193 required: entry.required,
194 status: if resolved_path.exists() {
195 DocumentStatus::Present
196 } else {
197 DocumentStatus::Missing
198 },
199 source: extension_source(config.source_scope),
200 why: extension_why(config, index, entry),
201 };
202
203 if let Some(existing_index) = extension_indices.get(&key).copied() {
204 extension_documents[existing_index] = document;
205 } else {
206 let next_index = extension_documents.len();
207 extension_documents.push(document);
208 extension_indices.insert(key, next_index);
209 }
210 }
211}
212
213fn extension_source(source_scope: Scope) -> DocumentSource {
214 match source_scope {
215 Scope::Home => DocumentSource::ExtensionHome,
216 Scope::Project => DocumentSource::ExtensionProject,
217 }
218}
219
220fn resolve_extension_path(entry: &ConfigDocumentEntry, roots: &ResolvedRoots) -> PathBuf {
221 let root = match entry.scope {
222 Scope::Home => &roots.codex_home,
223 Scope::Project => &roots.project_path,
224 };
225 normalize_path(&root.join(&entry.path))
226}
227
228fn resolve_extension_path_with_project_fallback(
229 entry: &ConfigDocumentEntry,
230 roots: &ResolvedRoots,
231 fallback_mode: FallbackMode,
232) -> PathBuf {
233 let local_path = resolve_extension_path(entry, roots);
234 if local_path.exists()
235 || !should_use_project_fallback(entry.scope, entry.required, fallback_mode)
236 {
237 return local_path;
238 }
239
240 let Some(primary_root) = project_fallback_root(roots, fallback_mode) else {
241 return local_path;
242 };
243
244 let fallback_path = normalize_path(&primary_root.join(&entry.path));
245 if fallback_path.exists() {
246 fallback_path
247 } else {
248 local_path
249 }
250}
251
252fn extension_why(config: &ConfigScopeFile, index: usize, entry: &ConfigDocumentEntry) -> String {
253 match entry
254 .notes
255 .as_deref()
256 .map(str::trim)
257 .filter(|notes| !notes.is_empty())
258 {
259 Some(notes) => format!(
260 "extension document from {} document[{index}] notes={notes}",
261 config.file_path.display()
262 ),
263 None => format!(
264 "extension document from {} document[{index}]",
265 config.file_path.display()
266 ),
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Hash)]
271struct ResolveKey {
272 context: &'static str,
273 scope: &'static str,
274 path: PathBuf,
275}
276
277impl ResolveKey {
278 fn new(context: Context, scope: Scope, path: PathBuf) -> Self {
279 Self {
280 context: context.as_str(),
281 scope: scope.as_str(),
282 path,
283 }
284 }
285
286 fn from_document(document: &ResolvedDocument) -> Self {
287 Self::new(document.context, document.scope, document.path.clone())
288 }
289}
290
291fn resolve_startup(roots: &ResolvedRoots, fallback_mode: FallbackMode) -> Vec<ResolvedDocument> {
292 vec![
293 resolve_startup_scope(Scope::Home, &roots.codex_home, None),
294 resolve_startup_scope(
295 Scope::Project,
296 &roots.project_path,
297 project_fallback_root(roots, fallback_mode),
298 ),
299 ]
300}
301
302fn resolve_startup_scope(
303 scope: Scope,
304 root: &Path,
305 fallback_root: Option<&Path>,
306) -> ResolvedDocument {
307 let override_path = normalize_path(&root.join("AGENTS.override.md"));
308 if override_path.exists() {
309 return ResolvedDocument {
310 context: Context::Startup,
311 scope,
312 path: override_path,
313 required: true,
314 status: DocumentStatus::Present,
315 source: DocumentSource::Builtin,
316 why: format!(
317 "startup {} policy (AGENTS.override.md preferred over AGENTS.md)",
318 scope
319 ),
320 };
321 }
322
323 let local_agents_path = normalize_path(&root.join("AGENTS.md"));
324 if local_agents_path.exists() {
325 return ResolvedDocument {
326 context: Context::Startup,
327 scope,
328 path: local_agents_path,
329 required: true,
330 status: DocumentStatus::Present,
331 source: DocumentSource::BuiltinFallback,
332 why: format!(
333 "startup {} policy (AGENTS.override.md missing, fallback AGENTS.md)",
334 scope
335 ),
336 };
337 }
338
339 if let Some(fallback_root) = fallback_root {
340 let fallback_override = normalize_path(&fallback_root.join("AGENTS.override.md"));
341 if fallback_override.exists() {
342 return ResolvedDocument {
343 context: Context::Startup,
344 scope,
345 path: fallback_override,
346 required: true,
347 status: DocumentStatus::Present,
348 source: DocumentSource::Builtin,
349 why: format!(
350 "startup {} policy (local missing, fallback to primary AGENTS.override.md)",
351 scope
352 ),
353 };
354 }
355
356 let fallback_agents = normalize_path(&fallback_root.join("AGENTS.md"));
357 if fallback_agents.exists() {
358 return ResolvedDocument {
359 context: Context::Startup,
360 scope,
361 path: fallback_agents,
362 required: true,
363 status: DocumentStatus::Present,
364 source: DocumentSource::BuiltinFallback,
365 why: format!(
366 "startup {} policy (local missing, fallback to primary AGENTS.md)",
367 scope
368 ),
369 };
370 }
371 }
372
373 resolve_required_doc(
374 Context::Startup,
375 scope,
376 root,
377 "AGENTS.md",
378 &format!(
379 "startup {} policy (AGENTS.override.md missing, fallback AGENTS.md)",
380 scope
381 ),
382 DocumentSource::BuiltinFallback,
383 )
384}
385
386fn resolve_required_doc_with_project_fallback(
387 context: Context,
388 scope: Scope,
389 root: &Path,
390 file_name: &str,
391 why: &str,
392 source: DocumentSource,
393 fallback_root: Option<&Path>,
394) -> ResolvedDocument {
395 let local_path = normalize_path(&root.join(file_name));
396 if local_path.exists() {
397 return ResolvedDocument {
398 context,
399 scope,
400 path: local_path,
401 required: true,
402 status: DocumentStatus::Present,
403 source,
404 why: why.to_string(),
405 };
406 }
407
408 if scope == Scope::Project
409 && let Some(fallback_root) = fallback_root
410 {
411 let fallback_path = normalize_path(&fallback_root.join(file_name));
412 if fallback_path.exists() {
413 return ResolvedDocument {
414 context,
415 scope,
416 path: fallback_path,
417 required: true,
418 status: DocumentStatus::Present,
419 source,
420 why: format!("{why} (fallback to primary worktree)"),
421 };
422 }
423 }
424
425 ResolvedDocument {
426 context,
427 scope,
428 path: local_path,
429 required: true,
430 status: DocumentStatus::Missing,
431 source,
432 why: why.to_string(),
433 }
434}
435
436fn project_fallback_root(roots: &ResolvedRoots, fallback_mode: FallbackMode) -> Option<&Path> {
437 if fallback_mode == FallbackMode::Auto && roots.is_linked_worktree {
438 roots.primary_worktree_path.as_deref()
439 } else {
440 None
441 }
442}
443
444fn should_use_project_fallback(scope: Scope, required: bool, fallback_mode: FallbackMode) -> bool {
445 scope == Scope::Project && required && fallback_mode == FallbackMode::Auto
446}
447
448fn resolve_required_doc(
449 context: Context,
450 scope: Scope,
451 root: &Path,
452 file_name: &str,
453 why: &str,
454 source: DocumentSource,
455) -> ResolvedDocument {
456 let path = normalize_path(&root.join(file_name));
457 let status = if path.exists() {
458 DocumentStatus::Present
459 } else {
460 DocumentStatus::Missing
461 };
462
463 ResolvedDocument {
464 context,
465 scope,
466 path,
467 required: true,
468 status,
469 source,
470 why: why.to_string(),
471 }
472}