Skip to main content

oino_resources/
context_files.rs

1//! Project context files (runtime §9.2).
2
3use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use oino_settings::paths::canonicalize_or_self;
8use serde::{Deserialize, Serialize};
9
10use crate::diagnostics::ResourceDiagnostic;
11
12/// Per-directory candidates, first existing file wins.
13pub const CONTEXT_FILE_CANDIDATES: [&str; 5] = [
14    "AGENTS.override.md",
15    "AGENTS.md",
16    "AGENTS.MD",
17    "CLAUDE.md",
18    "CLAUDE.MD",
19];
20
21/// A loaded context file.
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ContextFile {
24    /// File path.
25    pub path: PathBuf,
26    /// Content with a leading BOM removed.
27    pub content: String,
28}
29
30/// Context files plus read warnings (logged to stderr).
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ContextFiles {
33    /// Agent-dir file first, then ancestors from the filesystem root down to cwd.
34    pub files: Vec<ContextFile>,
35    /// `Could not read …` problems.
36    pub diagnostics: Vec<ResourceDiagnostic>,
37}
38
39fn load_from_dir(dir: &Path, diagnostics: &mut Vec<ResourceDiagnostic>) -> Option<ContextFile> {
40    for name in CONTEXT_FILE_CANDIDATES {
41        let path = dir.join(name);
42        let Ok(metadata) = fs::metadata(&path) else {
43            continue;
44        };
45        if !metadata.is_file() {
46            continue;
47        }
48        match fs::read_to_string(&path) {
49            Ok(content) => {
50                let content = content
51                    .strip_prefix('\u{feff}')
52                    .map_or(content.clone(), str::to_owned);
53                return Some(ContextFile { path, content });
54            }
55            Err(error) => diagnostics.push(ResourceDiagnostic::warning(
56                format!("Could not read {}: {error}", path.display()),
57                &path,
58            )),
59        }
60    }
61    None
62}
63
64/// Git layout around `cwd`.
65struct GitPaths {
66    repo_dir: PathBuf,
67    common_git_dir: PathBuf,
68}
69
70fn find_git_paths(cwd: &Path) -> Option<GitPaths> {
71    let mut dir = cwd.to_path_buf();
72    loop {
73        let git = dir.join(".git");
74        if let Ok(metadata) = fs::metadata(&git) {
75            if metadata.is_file() {
76                let content = fs::read_to_string(&git).ok()?;
77                let target = content.trim().strip_prefix("gitdir: ")?;
78                let git_dir = oino_settings::paths::lexical_normalize(&dir.join(target.trim()));
79                if !git_dir.join("HEAD").exists() {
80                    return None;
81                }
82                let common = match fs::read_to_string(git_dir.join("commondir")) {
83                    Ok(common) => {
84                        oino_settings::paths::lexical_normalize(&git_dir.join(common.trim()))
85                    }
86                    Err(_) => git_dir,
87                };
88                return Some(GitPaths {
89                    repo_dir: dir,
90                    common_git_dir: common,
91                });
92            }
93            if metadata.is_dir() {
94                if !git.join("HEAD").exists() {
95                    return None;
96                }
97                return Some(GitPaths {
98                    repo_dir: dir,
99                    common_git_dir: git,
100                });
101            }
102        }
103        if !dir.pop() {
104            return None;
105        }
106    }
107}
108
109/// The main repo's context file shadowed by a nested linked
110/// worktree's own copy (canonical path), if any.
111fn find_shadowed_context_file(cwd: &Path) -> Option<PathBuf> {
112    let git = find_git_paths(cwd)?;
113    let common = canonicalize_or_self(&git.common_git_dir);
114    let worktree_root = canonicalize_or_self(&git.repo_dir);
115    let main_root = common.parent()?.to_path_buf();
116    if worktree_root == main_root || !worktree_root.starts_with(&main_root) {
117        return None;
118    }
119    if canonicalize_or_self(&main_root.join(".git")) != common {
120        return None;
121    }
122    let worktree_file = load_from_dir(&worktree_root, &mut Vec::new())?;
123    Some(main_root.join(worktree_file.path.file_name()?))
124}
125
126/// The agent-dir file, then one file per ancestor directory from
127/// the filesystem root down to `cwd`; a path is loaded once and a nested git worktree's file
128/// shadows the main repository's. `cwd` and `agent_dir` must be absolute.
129#[must_use]
130pub fn load_project_context_files(cwd: &Path, agent_dir: &Path) -> ContextFiles {
131    let mut diagnostics = Vec::new();
132    let mut files = Vec::new();
133    let mut seen = HashSet::new();
134    if let Some(global) = load_from_dir(agent_dir, &mut diagnostics) {
135        seen.insert(global.path.clone());
136        files.push(global);
137    }
138    let shadowed = find_shadowed_context_file(cwd);
139    let mut ancestors = Vec::new();
140    for dir in cwd.ancestors() {
141        let Some(file) = load_from_dir(dir, &mut diagnostics) else {
142            continue;
143        };
144        let is_shadowed = shadowed.as_ref().is_some_and(|shadowed| {
145            file.path
146                .file_name()
147                .is_some_and(|name| &canonicalize_or_self(dir).join(name) == shadowed)
148        });
149        if !is_shadowed && seen.insert(file.path.clone()) {
150            ancestors.push(file);
151        }
152    }
153    ancestors.reverse();
154    files.extend(ancestors);
155    ContextFiles { files, diagnostics }
156}