oino_resources/
context_files.rs1use 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
12pub 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ContextFile {
24 pub path: PathBuf,
26 pub content: String,
28}
29
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ContextFiles {
33 pub files: Vec<ContextFile>,
35 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
64struct 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
109fn 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#[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}