Codex Handbook
config/src/diagnostics.rs 498 lines
//! Helpers for mapping config parse/validation failures to file locations and//! rendering them in a user-friendly way.use crate::ConfigLayerEntry;use crate::ConfigLayerStack;use crate::ConfigLayerStackOrdering;use crate::format_config_layer_source;use codex_app_server_protocol::ConfigLayerSource;use codex_utils_absolute_path::AbsolutePathBufGuard;use serde::de::DeserializeOwned;use serde_path_to_error::Path as SerdePath;use serde_path_to_error::Segment as SerdeSegment;use std::fmt;use std::fmt::Write;use std::io;use std::path::Path;use std::path::PathBuf;use toml_edit::Document;use toml_edit::Item;use toml_edit::Table;use toml_edit::Value;#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct TextPosition {    pub line: usize,    pub column: usize,}/// Text range in 1-based line/column coordinates.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct TextRange {    pub start: TextPosition,    pub end: TextPosition,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct ConfigError {    pub path: PathBuf,    pub range: TextRange,    pub message: String,}impl ConfigError {    pub fn new(path: PathBuf, range: TextRange, message: impl Into<String>) -> Self {        Self {            path,            range,            message: message.into(),        }    }}#[derive(Debug)]pub struct ConfigLoadError {    error: ConfigError,    source: Option<toml::de::Error>,}impl ConfigLoadError {    pub fn new(error: ConfigError, source: Option<toml::de::Error>) -> Self {        Self { error, source }    }    pub fn config_error(&self) -> &ConfigError {        &self.error    }}impl fmt::Display for ConfigLoadError {    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {        write!(            f,            "{}:{}:{}: {}",            self.error.path.display(),            self.error.range.start.line,            self.error.range.start.column,            self.error.message        )    }}impl std::error::Error for ConfigLoadError {    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {        self.source            .as_ref()            .map(|err| err as &dyn std::error::Error)    }}#[derive(Clone, Copy)]pub(crate) enum ConfigDiagnosticSource<'a> {    Path(&'a Path),    DisplayName(&'a str),}impl ConfigDiagnosticSource<'_> {    pub(crate) fn to_path_buf(self) -> PathBuf {        match self {            ConfigDiagnosticSource::Path(path) => path.to_path_buf(),            ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),        }    }}pub fn io_error_from_config_error(    kind: io::ErrorKind,    error: ConfigError,    source: Option<toml::de::Error>,) -> io::Error {    io::Error::new(kind, ConfigLoadError::new(error, source))}pub fn config_error_from_toml(    path: impl AsRef<Path>,    contents: &str,    err: toml::de::Error,) -> ConfigError {    config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)}pub(crate) fn config_error_from_toml_for_source(    source: ConfigDiagnosticSource<'_>,    contents: &str,    err: toml::de::Error,) -> ConfigError {    let range = err        .span()        .map(|span| text_range_from_span(contents, span))        .unwrap_or_else(default_range);    ConfigError::new(source.to_path_buf(), range, err.message())}pub fn config_error_from_typed_toml<T: DeserializeOwned>(    path: impl AsRef<Path>,    contents: &str,) -> Option<ConfigError> {    config_error_from_typed_toml_for_source::<T>(        ConfigDiagnosticSource::Path(path.as_ref()),        contents,    )}fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(    source: ConfigDiagnosticSource<'_>,    contents: &str,) -> Option<ConfigError> {    let deserializer = match toml::de::Deserializer::parse(contents) {        Ok(deserializer) => deserializer,        Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),    };    let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);    match result {        Ok(_) => None,        Err(err) => {            let path_hint = err.path().clone();            let toml_err: toml::de::Error = err.into_inner();            let range = span_for_config_path(contents, &path_hint)                .or_else(|| toml_err.span())                .map(|span| text_range_from_span(contents, span))                .unwrap_or_else(default_range);            Some(ConfigError::new(                source.to_path_buf(),                range,                toml_err.message(),            ))        }    }}pub async fn first_layer_config_error<T: DeserializeOwned>(    layers: &ConfigLayerStack,    config_toml_file: &str,) -> Option<ConfigError> {    // When the merged config fails schema validation, we surface the first concrete    // per-file error to point users at a specific file and range rather than an    // opaque merged-layer failure.    first_layer_config_error_for_entries::<T, _>(        layers.get_layers(            ConfigLayerStackOrdering::LowestPrecedenceFirst,            /*include_disabled*/ false,        ),        config_toml_file,    )    .await}pub async fn first_layer_config_error_from_entries<T: DeserializeOwned>(    layers: &[ConfigLayerEntry],    config_toml_file: &str,) -> Option<ConfigError> {    first_layer_config_error_for_entries::<T, _>(layers.iter(), config_toml_file).await}async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>(    layers: I,    config_toml_file: &str,) -> Option<ConfigError>where    I: IntoIterator<Item = &'a ConfigLayerEntry>,{    for layer in layers {        if let Some(contents) = layer.raw_toml() {            let source_name = format_config_layer_source(&layer.name, config_toml_file);            let Some(base_dir) = layer.raw_toml_base_dir() else {                tracing::debug!(                    "Skipping raw TOML diagnostics for {source_name} because it has no base directory"                );                continue;            };            // Match the base directory used when the raw non-file layer was            // parsed into the runtime layer so diagnostics resolve relative            // path fields with the same semantics.            let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path());            if let Some(error) = config_error_from_typed_toml_for_source::<T>(                ConfigDiagnosticSource::DisplayName(&source_name),                contents,            ) {                return Some(error);            }            continue;        }        let Some(path) = config_path_for_layer(layer, config_toml_file) else {            continue;        };        let contents = match tokio::fs::read_to_string(&path).await {            Ok(contents) => contents,            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,            Err(err) => {                tracing::debug!("Failed to read config file {}: {err}", path.display());                continue;            }        };        let Some(parent) = path.parent() else {            tracing::debug!("Config file {} has no parent directory", path.display());            continue;        };        let _guard = AbsolutePathBufGuard::new(parent);        if let Some(error) = config_error_from_typed_toml::<T>(&path, &contents) {            return Some(error);        }    }    None}fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {    match &layer.name {        ConfigLayerSource::System { file } => Some(file.to_path_buf()),        ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()),        ConfigLayerSource::Project { dot_codex_folder } => {            Some(dot_codex_folder.as_path().join(config_toml_file))        }        ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),        ConfigLayerSource::Mdm { .. }        | ConfigLayerSource::EnterpriseManaged { .. }        | ConfigLayerSource::SessionFlags        | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,    }}pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {    let start = position_for_offset(contents, span.start);    let end_index = if span.end > span.start {        span.end - 1    } else {        span.end    };    let end = position_for_offset(contents, end_index);    TextRange { start, end }}pub fn format_config_error(error: &ConfigError, contents: &str) -> String {    let mut output = String::new();    let start = error.range.start;    let _ = writeln!(        output,        "{}:{}:{}: {}",        error.path.display(),        start.line,        start.column,        error.message    );    let line_index = start.line.saturating_sub(1);    let line = match contents.lines().nth(line_index) {        Some(line) => line.trim_end_matches('\r'),        None => return output.trim_end().to_string(),    };    let line_number = start.line;    let gutter = line_number.to_string().len();    let _ = writeln!(output, "{:width$} |", "", width = gutter);    let _ = writeln!(output, "{line_number:>gutter$} | {line}");    let highlight_len = if error.range.end.line == error.range.start.line        && error.range.end.column >= error.range.start.column    {        error.range.end.column - error.range.start.column + 1    } else {        1    };    let spaces = " ".repeat(start.column.saturating_sub(1));    let carets = "^".repeat(highlight_len.max(1));    let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter);    output.trim_end().to_string()}pub fn format_config_error_with_source(error: &ConfigError) -> String {    match std::fs::read_to_string(&error.path) {        Ok(contents) => format_config_error(error, &contents),        Err(_) => format_config_error(error, ""),    }}fn position_for_offset(contents: &str, index: usize) -> TextPosition {    let bytes = contents.as_bytes();    if bytes.is_empty() {        return TextPosition { line: 1, column: 1 };    }    let safe_index = index.min(bytes.len().saturating_sub(1));    let column_offset = index.saturating_sub(safe_index);    let index = safe_index;    let line_start = bytes[..index]        .iter()        .rposition(|byte| *byte == b'\n')        .map(|pos| pos + 1)        .unwrap_or(0);    let line = bytes[..line_start]        .iter()        .filter(|byte| **byte == b'\n')        .count();    let column = std::str::from_utf8(&bytes[line_start..=index])        .map(|slice| slice.chars().count().saturating_sub(1))        .unwrap_or_else(|_| index - line_start);    let column = column + column_offset;    TextPosition {        line: line + 1,        column: column + 1,    }}pub(crate) fn default_range() -> TextRange {    let position = TextPosition { line: 1, column: 1 };    TextRange {        start: position,        end: position,    }}enum TomlNode<'a> {    Item(&'a Item),    Table(&'a Table),    Value(&'a Value),}fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {    let doc = contents.parse::<Document<String>>().ok()?;    let node = node_for_path(doc.as_item(), path)?;    match node {        TomlNode::Item(item) => item.span(),        TomlNode::Table(table) => table.span(),        TomlNode::Value(value) => value.span(),    }}pub(crate) fn span_for_config_path(    contents: &str,    path: &SerdePath,) -> Option<std::ops::Range<usize>> {    if is_features_table_path(path)        && let Some(span) = span_for_features_value(contents)    {        return Some(span);    }    span_for_path(contents, path)}pub(crate) fn span_for_toml_key_path(    contents: &str,    path: &[String],) -> Option<std::ops::Range<usize>> {    let doc = contents.parse::<Document<String>>().ok()?;    let mut node = TomlNode::Item(doc.as_item());    for (index, segment) in path.iter().enumerate() {        if index + 1 == path.len() {            let key_span = match &node {                TomlNode::Item(item) => item                    .as_table_like()                    .and_then(|table| table.get_key_value(segment))                    .and_then(|(key, _)| key.span()),                TomlNode::Table(table) => {                    table.get_key_value(segment).and_then(|(key, _)| key.span())                }                TomlNode::Value(Value::InlineTable(table)) => {                    table.get_key_value(segment).and_then(|(key, _)| key.span())                }                _ => None,            };            if key_span.is_some() {                return key_span;            }        }        if let Some(next) = map_child(&node, segment) {            node = next;            continue;        }        let index = segment.parse::<usize>().ok()?;        node = seq_child(&node, index)?;    }    match node {        TomlNode::Item(item) => item.span(),        TomlNode::Table(table) => table.span(),        TomlNode::Value(value) => value.span(),    }}fn is_features_table_path(path: &SerdePath) -> bool {    let mut segments = path.iter();    matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")        && segments.next().is_none()}fn span_for_features_value(contents: &str) -> Option<std::ops::Range<usize>> {    let doc = contents.parse::<Document<String>>().ok()?;    let root = doc.as_item().as_table_like()?;    let features_item = root.get("features")?;    let features_table = features_item.as_table_like()?;    for (_, item) in features_table.iter() {        match item {            Item::Value(Value::Boolean(_)) => continue,            Item::Value(value) => return value.span(),            Item::Table(table) => return table.span(),            Item::ArrayOfTables(array) => return array.span(),            Item::None => continue,        }    }    None}fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option<TomlNode<'a>> {    let segments: Vec<_> = path.iter().cloned().collect();    let mut node = TomlNode::Item(item);    let mut index = 0;    while index < segments.len() {        match &segments[index] {            SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => {                if let Some(next) = map_child(&node, key) {                    node = next;                    index += 1;                    continue;                }                if index + 1 < segments.len() {                    index += 1;                    continue;                }                return None;            }            SerdeSegment::Seq { index: seq_index } => {                node = seq_child(&node, *seq_index)?;                index += 1;            }            SerdeSegment::Unknown => return None,        }    }    Some(node)}fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option<TomlNode<'a>> {    match node {        TomlNode::Item(item) => {            let table = item.as_table_like()?;            table.get(key).map(TomlNode::Item)        }        TomlNode::Table(table) => table.get(key).map(TomlNode::Item),        TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value),        _ => None,    }}fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option<TomlNode<'a>> {    match node {        TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value),        TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table),        TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value),        _ => None,    }}