Skip to main content

agent_docs/
config.rs

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}