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.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}