1use std::fs;
2use std::path::{Path, PathBuf};
3
4use toml::Value;
5
6use crate::env::ResolvedRoots;
7use crate::model::{
8 ConfigDocumentEntry, ConfigErrorLocation, ConfigLoadError, ConfigScopeFile, Context,
9 DocumentWhen, LoadedConfigs, Scope,
10};
11
12pub const CONFIG_FILE_NAME: &str = "AGENT_DOCS.toml";
13
14const ALLOWED_DOCUMENT_FIELDS: [&str; 6] =
15 ["context", "scope", "path", "required", "when", "notes"];
16
17pub fn config_path_for_root(root: &Path) -> PathBuf {
18 root.join(CONFIG_FILE_NAME)
19}
20
21pub fn load_configs_from_roots(roots: &ResolvedRoots) -> Result<LoadedConfigs, ConfigLoadError> {
22 load_configs(&roots.codex_home, &roots.project_path)
23}
24
25pub fn load_configs(
26 codex_home: &Path,
27 project_path: &Path,
28) -> Result<LoadedConfigs, ConfigLoadError> {
29 let home = load_scope_config(Scope::Home, codex_home)?;
30 let project = load_scope_config(Scope::Project, project_path)?;
31 Ok(LoadedConfigs { home, project })
32}
33
34pub fn load_scope_config(
35 source_scope: Scope,
36 root: &Path,
37) -> Result<Option<ConfigScopeFile>, ConfigLoadError> {
38 let file_path = config_path_for_root(root);
39 if !file_path.exists() {
40 return Ok(None);
41 }
42
43 let raw = fs::read_to_string(&file_path).map_err(|err| {
44 ConfigLoadError::io(
45 file_path.clone(),
46 format!("failed to read {}: {err}", CONFIG_FILE_NAME),
47 )
48 })?;
49 let parsed = parse_toml(&file_path, &raw)?;
50 let documents = parse_documents(&file_path, &parsed)?;
51
52 Ok(Some(ConfigScopeFile {
53 source_scope,
54 root: root.to_path_buf(),
55 file_path,
56 documents,
57 }))
58}
59
60fn parse_toml(file_path: &Path, raw: &str) -> Result<Value, ConfigLoadError> {
61 raw.parse::<Value>()
62 .map_err(|err| parse_error(file_path, raw, &err))
63}
64
65fn parse_documents(
66 file_path: &Path,
67 parsed: &Value,
68) -> Result<Vec<ConfigDocumentEntry>, ConfigLoadError> {
69 let Some(root_table) = parsed.as_table() else {
70 return Err(ConfigLoadError::validation_root(
71 file_path.to_path_buf(),
72 "document",
73 "root TOML value must be a table",
74 ));
75 };
76
77 let Some(raw_documents) = root_table.get("document") else {
78 return Ok(Vec::new());
79 };
80 let Some(raw_documents) = raw_documents.as_array() else {
81 return Err(ConfigLoadError::validation_root(
82 file_path.to_path_buf(),
83 "document",
84 "key `document` must be an array of [[document]] tables",
85 ));
86 };
87
88 let mut documents = Vec::with_capacity(raw_documents.len());
89 for (index, raw_document) in raw_documents.iter().enumerate() {
90 let Some(table) = raw_document.as_table() else {
91 return Err(ConfigLoadError::validation(
92 file_path.to_path_buf(),
93 index,
94 "document",
95 "entry must be a TOML table declared with [[document]]",
96 ));
97 };
98
99 validate_unknown_fields(file_path, index, table)?;
100 let context = parse_context(file_path, index, table)?;
101 let scope = parse_scope(file_path, index, table)?;
102 let path = parse_path(file_path, index, table)?;
103 let required = parse_required(file_path, index, table)?;
104 let when = parse_when(file_path, index, table)?;
105 let notes = parse_notes(file_path, index, table)?;
106
107 documents.push(ConfigDocumentEntry {
108 context,
109 scope,
110 path,
111 required,
112 when,
113 notes,
114 });
115 }
116
117 Ok(documents)
118}
119
120fn validate_unknown_fields(
121 file_path: &Path,
122 index: usize,
123 table: &toml::map::Map<String, Value>,
124) -> Result<(), ConfigLoadError> {
125 for key in table.keys() {
126 if !ALLOWED_DOCUMENT_FIELDS.contains(&key.as_str()) {
127 return Err(ConfigLoadError::validation(
128 file_path.to_path_buf(),
129 index,
130 key,
131 format!(
132 "unsupported field `{key}`; allowed fields: {}",
133 ALLOWED_DOCUMENT_FIELDS.join(", ")
134 ),
135 ));
136 }
137 }
138 Ok(())
139}
140
141fn parse_context(
142 file_path: &Path,
143 index: usize,
144 table: &toml::map::Map<String, Value>,
145) -> Result<Context, ConfigLoadError> {
146 let raw = required_string(file_path, index, table, "context")?;
147 Context::from_config_value(raw).ok_or_else(|| {
148 ConfigLoadError::validation(
149 file_path.to_path_buf(),
150 index,
151 "context",
152 format!(
153 "unsupported context `{raw}`; allowed: {}",
154 Context::supported_values().join(", ")
155 ),
156 )
157 })
158}
159
160fn parse_scope(
161 file_path: &Path,
162 index: usize,
163 table: &toml::map::Map<String, Value>,
164) -> Result<Scope, ConfigLoadError> {
165 let raw = required_string(file_path, index, table, "scope")?;
166 Scope::from_config_value(raw).ok_or_else(|| {
167 ConfigLoadError::validation(
168 file_path.to_path_buf(),
169 index,
170 "scope",
171 format!(
172 "unsupported scope `{raw}`; allowed: {}",
173 Scope::supported_values().join(", ")
174 ),
175 )
176 })
177}
178
179fn parse_path(
180 file_path: &Path,
181 index: usize,
182 table: &toml::map::Map<String, Value>,
183) -> Result<PathBuf, ConfigLoadError> {
184 let raw = required_string(file_path, index, table, "path")?;
185 let trimmed = raw.trim();
186 if trimmed.is_empty() {
187 return Err(ConfigLoadError::validation(
188 file_path.to_path_buf(),
189 index,
190 "path",
191 "path cannot be empty",
192 ));
193 }
194 Ok(PathBuf::from(trimmed))
195}
196
197fn parse_required(
198 file_path: &Path,
199 index: usize,
200 table: &toml::map::Map<String, Value>,
201) -> Result<bool, ConfigLoadError> {
202 let Some(value) = table.get("required") else {
203 return Ok(false);
204 };
205 let Some(required) = value.as_bool() else {
206 return Err(ConfigLoadError::validation(
207 file_path.to_path_buf(),
208 index,
209 "required",
210 format!(
211 "invalid type for `required`: expected boolean, found {}",
212 value_type(value)
213 ),
214 ));
215 };
216 Ok(required)
217}
218
219fn parse_when(
220 file_path: &Path,
221 index: usize,
222 table: &toml::map::Map<String, Value>,
223) -> Result<DocumentWhen, ConfigLoadError> {
224 let when_value = match table.get("when") {
225 Some(value) => {
226 let Some(value) = value.as_str() else {
227 return Err(ConfigLoadError::validation(
228 file_path.to_path_buf(),
229 index,
230 "when",
231 format!(
232 "invalid type for `when`: expected string, found {}",
233 value_type(value)
234 ),
235 ));
236 };
237 value
238 }
239 None => "always",
240 };
241
242 DocumentWhen::from_config_value(when_value).ok_or_else(|| {
243 ConfigLoadError::validation(
244 file_path.to_path_buf(),
245 index,
246 "when",
247 format!(
248 "unsupported when value `{when_value}`; allowed: {}",
249 DocumentWhen::supported_values().join(", ")
250 ),
251 )
252 })
253}
254
255fn parse_notes(
256 file_path: &Path,
257 index: usize,
258 table: &toml::map::Map<String, Value>,
259) -> Result<Option<String>, ConfigLoadError> {
260 let Some(value) = table.get("notes") else {
261 return Ok(None);
262 };
263 let Some(notes) = value.as_str() else {
264 return Err(ConfigLoadError::validation(
265 file_path.to_path_buf(),
266 index,
267 "notes",
268 format!(
269 "invalid type for `notes`: expected string, found {}",
270 value_type(value)
271 ),
272 ));
273 };
274 Ok(Some(notes.to_string()))
275}
276
277fn required_string<'a>(
278 file_path: &Path,
279 index: usize,
280 table: &'a toml::map::Map<String, Value>,
281 field: &'static str,
282) -> Result<&'a str, ConfigLoadError> {
283 let Some(value) = table.get(field) else {
284 return Err(ConfigLoadError::validation(
285 file_path.to_path_buf(),
286 index,
287 field,
288 format!("missing required field `{field}`"),
289 ));
290 };
291 let Some(value) = value.as_str() else {
292 return Err(ConfigLoadError::validation(
293 file_path.to_path_buf(),
294 index,
295 field,
296 format!(
297 "invalid type for `{field}`: expected string, found {}",
298 value_type(value)
299 ),
300 ));
301 };
302 Ok(value)
303}
304
305fn value_type(value: &Value) -> &'static str {
306 match value {
307 Value::String(_) => "string",
308 Value::Integer(_) => "integer",
309 Value::Float(_) => "float",
310 Value::Boolean(_) => "boolean",
311 Value::Datetime(_) => "datetime",
312 Value::Array(_) => "array",
313 Value::Table(_) => "table",
314 }
315}
316
317fn parse_error(file_path: &Path, raw: &str, err: &toml::de::Error) -> ConfigLoadError {
318 let location = err
319 .span()
320 .map(|span| byte_offset_to_line_column(raw, span.start));
321
322 ConfigLoadError::parse(
323 file_path.to_path_buf(),
324 format!("invalid TOML in {CONFIG_FILE_NAME}: {err}"),
325 location,
326 )
327}
328
329fn byte_offset_to_line_column(raw: &str, offset: usize) -> ConfigErrorLocation {
330 let mut line = 1usize;
331 let mut column = 1usize;
332 let clamped = offset.min(raw.len());
333 for (idx, ch) in raw.char_indices() {
334 if idx >= clamped {
335 break;
336 }
337 if ch == '\n' {
338 line += 1;
339 column = 1;
340 } else {
341 column += 1;
342 }
343 }
344 ConfigErrorLocation { line, column }
345}