Skip to main content

codex_config/
diagnostics.rs

1//! Helpers for mapping config parse/validation failures to file locations and
2//! rendering them in a user-friendly way.
3
4use crate::ConfigLayerEntry;
5use crate::ConfigLayerSource;
6use crate::ConfigLayerStack;
7use crate::ConfigLayerStackOrdering;
8use crate::format_config_layer_source;
9use codex_utils_absolute_path::AbsolutePathBufGuard;
10use serde::de::DeserializeOwned;
11use serde_path_to_error::Path as SerdePath;
12use serde_path_to_error::Segment as SerdeSegment;
13use std::fmt;
14use std::fmt::Write;
15use std::io;
16use std::path::Path;
17use std::path::PathBuf;
18use toml_edit::Document;
19use toml_edit::Item;
20use toml_edit::Table;
21use toml_edit::Value;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct TextPosition {
25    pub line: usize,
26    pub column: usize,
27}
28
29/// Text range in 1-based line/column coordinates.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct TextRange {
32    pub start: TextPosition,
33    pub end: TextPosition,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ConfigError {
38    pub path: PathBuf,
39    pub range: TextRange,
40    pub message: String,
41}
42
43impl ConfigError {
44    pub fn new(path: PathBuf, range: TextRange, message: impl Into<String>) -> Self {
45        Self {
46            path,
47            range,
48            message: message.into(),
49        }
50    }
51}
52
53#[derive(Debug)]
54pub struct ConfigLoadError {
55    error: ConfigError,
56    source: Option<toml::de::Error>,
57}
58
59impl ConfigLoadError {
60    pub fn new(error: ConfigError, source: Option<toml::de::Error>) -> Self {
61        Self { error, source }
62    }
63
64    pub fn config_error(&self) -> &ConfigError {
65        &self.error
66    }
67}
68
69impl fmt::Display for ConfigLoadError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(
72            f,
73            "{}:{}:{}: {}",
74            self.error.path.display(),
75            self.error.range.start.line,
76            self.error.range.start.column,
77            self.error.message
78        )
79    }
80}
81
82impl std::error::Error for ConfigLoadError {
83    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84        self.source
85            .as_ref()
86            .map(|err| err as &dyn std::error::Error)
87    }
88}
89
90#[derive(Clone, Copy)]
91pub(crate) enum ConfigDiagnosticSource<'a> {
92    Path(&'a Path),
93    DisplayName(&'a str),
94}
95
96impl ConfigDiagnosticSource<'_> {
97    pub(crate) fn to_path_buf(self) -> PathBuf {
98        match self {
99            ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
100            ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
101        }
102    }
103}
104
105pub fn io_error_from_config_error(
106    kind: io::ErrorKind,
107    error: ConfigError,
108    source: Option<toml::de::Error>,
109) -> io::Error {
110    io::Error::new(kind, ConfigLoadError::new(error, source))
111}
112
113pub fn config_error_from_toml(
114    path: impl AsRef<Path>,
115    contents: &str,
116    err: toml::de::Error,
117) -> ConfigError {
118    config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)
119}
120
121pub(crate) fn config_error_from_toml_for_source(
122    source: ConfigDiagnosticSource<'_>,
123    contents: &str,
124    err: toml::de::Error,
125) -> ConfigError {
126    let range = err
127        .span()
128        .map(|span| text_range_from_span(contents, span))
129        .unwrap_or_else(default_range);
130    ConfigError::new(source.to_path_buf(), range, err.message())
131}
132
133pub fn config_error_from_typed_toml<T: DeserializeOwned>(
134    path: impl AsRef<Path>,
135    contents: &str,
136) -> Option<ConfigError> {
137    config_error_from_typed_toml_for_source::<T>(
138        ConfigDiagnosticSource::Path(path.as_ref()),
139        contents,
140    )
141}
142
143fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
144    source: ConfigDiagnosticSource<'_>,
145    contents: &str,
146) -> Option<ConfigError> {
147    let deserializer = match toml::de::Deserializer::parse(contents) {
148        Ok(deserializer) => deserializer,
149        Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),
150    };
151
152    let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
153    match result {
154        Ok(_) => None,
155        Err(err) => {
156            let path_hint = err.path().clone();
157            let toml_err: toml::de::Error = err.into_inner();
158            let range = span_for_config_path(contents, &path_hint)
159                .or_else(|| toml_err.span())
160                .map(|span| text_range_from_span(contents, span))
161                .unwrap_or_else(default_range);
162            Some(ConfigError::new(
163                source.to_path_buf(),
164                range,
165                toml_err.message(),
166            ))
167        }
168    }
169}
170
171pub async fn first_layer_config_error<T: DeserializeOwned>(
172    layers: &ConfigLayerStack,
173    config_toml_file: &str,
174) -> Option<ConfigError> {
175    // When the merged config fails schema validation, we surface the first concrete
176    // per-file error to point users at a specific file and range rather than an
177    // opaque merged-layer failure.
178    first_layer_config_error_for_entries::<T, _>(
179        layers.get_layers(
180            ConfigLayerStackOrdering::LowestPrecedenceFirst,
181            /*include_disabled*/ false,
182        ),
183        config_toml_file,
184    )
185    .await
186}
187
188pub async fn first_layer_config_error_from_entries<T: DeserializeOwned>(
189    layers: &[ConfigLayerEntry],
190    config_toml_file: &str,
191) -> Option<ConfigError> {
192    first_layer_config_error_for_entries::<T, _>(layers.iter(), config_toml_file).await
193}
194
195async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>(
196    layers: I,
197    config_toml_file: &str,
198) -> Option<ConfigError>
199where
200    I: IntoIterator<Item = &'a ConfigLayerEntry>,
201{
202    for layer in layers {
203        if layer.is_disabled() {
204            continue;
205        }
206        if let Some(contents) = layer.raw_toml() {
207            let source_name = format_config_layer_source(&layer.name, config_toml_file);
208            let Some(base_dir) = layer.raw_toml_base_dir() else {
209                tracing::debug!(
210                    "Skipping raw TOML diagnostics for {source_name} because it has no base directory"
211                );
212                continue;
213            };
214            // Match the base directory used when the raw non-file layer was
215            // parsed into the runtime layer so diagnostics resolve relative
216            // path fields with the same semantics.
217            let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path());
218            if let Some(error) = config_error_from_typed_toml_for_source::<T>(
219                ConfigDiagnosticSource::DisplayName(&source_name),
220                contents,
221            ) {
222                return Some(error);
223            }
224            continue;
225        }
226
227        let Some(path) = config_path_for_layer(layer, config_toml_file) else {
228            continue;
229        };
230        let contents = match tokio::fs::read_to_string(&path).await {
231            Ok(contents) => contents,
232            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
233            Err(err) => {
234                tracing::debug!("Failed to read config file {}: {err}", path.display());
235                continue;
236            }
237        };
238
239        let Some(parent) = path.parent() else {
240            tracing::debug!("Config file {} has no parent directory", path.display());
241            continue;
242        };
243        let _guard = AbsolutePathBufGuard::new(parent);
244        if let Some(error) = config_error_from_typed_toml::<T>(&path, &contents) {
245            return Some(error);
246        }
247    }
248
249    None
250}
251
252fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {
253    match &layer.name {
254        ConfigLayerSource::System { file } => Some(file.to_path_buf()),
255        ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()),
256        ConfigLayerSource::Project { dot_codex_folder } => {
257            Some(dot_codex_folder.as_path().join(config_toml_file))
258        }
259        ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),
260        ConfigLayerSource::Mdm { .. }
261        | ConfigLayerSource::EnterpriseManaged { .. }
262        | ConfigLayerSource::SessionFlags
263        | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
264    }
265}
266
267pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
268    let start = position_for_offset(contents, span.start);
269    let end_index = if span.end > span.start {
270        span.end - 1
271    } else {
272        span.end
273    };
274    let end = position_for_offset(contents, end_index);
275    TextRange { start, end }
276}
277
278pub fn format_config_error(error: &ConfigError, contents: &str) -> String {
279    let mut output = String::new();
280    let start = error.range.start;
281    let _ = writeln!(
282        output,
283        "{}:{}:{}: {}",
284        error.path.display(),
285        start.line,
286        start.column,
287        error.message
288    );
289
290    let line_index = start.line.saturating_sub(1);
291    let line = match contents.lines().nth(line_index) {
292        Some(line) => line.trim_end_matches('\r'),
293        None => return output.trim_end().to_string(),
294    };
295
296    let line_number = start.line;
297    let gutter = line_number.to_string().len();
298    let _ = writeln!(output, "{:width$} |", "", width = gutter);
299    let _ = writeln!(output, "{line_number:>gutter$} | {line}");
300
301    let highlight_len = if error.range.end.line == error.range.start.line
302        && error.range.end.column >= error.range.start.column
303    {
304        error.range.end.column - error.range.start.column + 1
305    } else {
306        1
307    };
308    let spaces = " ".repeat(start.column.saturating_sub(1));
309    let carets = "^".repeat(highlight_len.max(1));
310    let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter);
311    output.trim_end().to_string()
312}
313
314pub fn format_config_error_with_source(error: &ConfigError) -> String {
315    match std::fs::read_to_string(&error.path) {
316        Ok(contents) => format_config_error(error, &contents),
317        Err(_) => format_config_error(error, ""),
318    }
319}
320
321fn position_for_offset(contents: &str, index: usize) -> TextPosition {
322    let bytes = contents.as_bytes();
323    if bytes.is_empty() {
324        return TextPosition { line: 1, column: 1 };
325    }
326
327    let safe_index = index.min(bytes.len().saturating_sub(1));
328    let column_offset = index.saturating_sub(safe_index);
329    let index = safe_index;
330
331    let line_start = bytes[..index]
332        .iter()
333        .rposition(|byte| *byte == b'\n')
334        .map(|pos| pos + 1)
335        .unwrap_or(0);
336    let line = bytes[..line_start]
337        .iter()
338        .filter(|byte| **byte == b'\n')
339        .count();
340
341    let column = std::str::from_utf8(&bytes[line_start..=index])
342        .map(|slice| slice.chars().count().saturating_sub(1))
343        .unwrap_or_else(|_| index - line_start);
344    let column = column + column_offset;
345
346    TextPosition {
347        line: line + 1,
348        column: column + 1,
349    }
350}
351
352pub(crate) fn default_range() -> TextRange {
353    let position = TextPosition { line: 1, column: 1 };
354    TextRange {
355        start: position,
356        end: position,
357    }
358}
359
360enum TomlNode<'a> {
361    Item(&'a Item),
362    Table(&'a Table),
363    Value(&'a Value),
364}
365
366fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {
367    let doc = contents.parse::<Document<String>>().ok()?;
368    let node = node_for_path(doc.as_item(), path)?;
369    match node {
370        TomlNode::Item(item) => item.span(),
371        TomlNode::Table(table) => table.span(),
372        TomlNode::Value(value) => value.span(),
373    }
374}
375
376pub(crate) fn span_for_config_path(
377    contents: &str,
378    path: &SerdePath,
379) -> Option<std::ops::Range<usize>> {
380    if is_features_table_path(path)
381        && let Some(span) = span_for_features_value(contents)
382    {
383        return Some(span);
384    }
385    span_for_path(contents, path)
386}
387
388pub(crate) fn span_for_toml_key_path(
389    contents: &str,
390    path: &[String],
391) -> Option<std::ops::Range<usize>> {
392    let doc = contents.parse::<Document<String>>().ok()?;
393    let mut node = TomlNode::Item(doc.as_item());
394    for (index, segment) in path.iter().enumerate() {
395        if index + 1 == path.len() {
396            let key_span = match &node {
397                TomlNode::Item(item) => item
398                    .as_table_like()
399                    .and_then(|table| table.get_key_value(segment))
400                    .and_then(|(key, _)| key.span()),
401                TomlNode::Table(table) => {
402                    table.get_key_value(segment).and_then(|(key, _)| key.span())
403                }
404                TomlNode::Value(Value::InlineTable(table)) => {
405                    table.get_key_value(segment).and_then(|(key, _)| key.span())
406                }
407                _ => None,
408            };
409            if key_span.is_some() {
410                return key_span;
411            }
412        }
413
414        if let Some(next) = map_child(&node, segment) {
415            node = next;
416            continue;
417        }
418
419        let index = segment.parse::<usize>().ok()?;
420        node = seq_child(&node, index)?;
421    }
422
423    match node {
424        TomlNode::Item(item) => item.span(),
425        TomlNode::Table(table) => table.span(),
426        TomlNode::Value(value) => value.span(),
427    }
428}
429
430fn is_features_table_path(path: &SerdePath) -> bool {
431    let mut segments = path.iter();
432    matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")
433        && segments.next().is_none()
434}
435
436fn span_for_features_value(contents: &str) -> Option<std::ops::Range<usize>> {
437    let doc = contents.parse::<Document<String>>().ok()?;
438    let root = doc.as_item().as_table_like()?;
439    let features_item = root.get("features")?;
440    let features_table = features_item.as_table_like()?;
441    for (_, item) in features_table.iter() {
442        match item {
443            Item::Value(Value::Boolean(_)) => continue,
444            Item::Value(value) => return value.span(),
445            Item::Table(table) => return table.span(),
446            Item::ArrayOfTables(array) => return array.span(),
447            Item::None => continue,
448        }
449    }
450    None
451}
452
453fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option<TomlNode<'a>> {
454    let segments: Vec<_> = path.iter().cloned().collect();
455    let mut node = TomlNode::Item(item);
456    let mut index = 0;
457    while index < segments.len() {
458        match &segments[index] {
459            SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => {
460                if let Some(next) = map_child(&node, key) {
461                    node = next;
462                    index += 1;
463                    continue;
464                }
465
466                if index + 1 < segments.len() {
467                    index += 1;
468                    continue;
469                }
470                return None;
471            }
472            SerdeSegment::Seq { index: seq_index } => {
473                node = seq_child(&node, *seq_index)?;
474                index += 1;
475            }
476            SerdeSegment::Unknown => return None,
477        }
478    }
479    Some(node)
480}
481
482fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option<TomlNode<'a>> {
483    match node {
484        TomlNode::Item(item) => {
485            let table = item.as_table_like()?;
486            table.get(key).map(TomlNode::Item)
487        }
488        TomlNode::Table(table) => table.get(key).map(TomlNode::Item),
489        TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value),
490        _ => None,
491    }
492}
493
494fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option<TomlNode<'a>> {
495    match node {
496        TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value),
497        TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table),
498        TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value),
499        _ => None,
500    }
501}