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