Skip to main content

agent_docs/
resolver.rs

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}