mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
583 lines
18 KiB
Rust
583 lines
18 KiB
Rust
//! 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 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::collections::HashSet;
|
|
use std::fmt;
|
|
use std::fmt::Write;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use toml::Value as TomlValue;
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let range = err
|
|
.span()
|
|
.map(|span| text_range_from_span(contents, span))
|
|
.unwrap_or_else(default_range);
|
|
ConfigError::new(path.as_ref().to_path_buf(), range, err.message())
|
|
}
|
|
|
|
pub fn config_error_from_typed_toml<T: DeserializeOwned>(
|
|
path: impl AsRef<Path>,
|
|
contents: &str,
|
|
) -> Option<ConfigError> {
|
|
let deserializer = match toml::de::Deserializer::parse(contents) {
|
|
Ok(deserializer) => deserializer,
|
|
Err(err) => return Some(config_error_from_toml(path, 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(
|
|
path.as_ref().to_path_buf(),
|
|
range,
|
|
toml_err.message(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct SanitizedTomlValue {
|
|
pub value: TomlValue,
|
|
pub dropped_entries: Vec<String>,
|
|
}
|
|
|
|
pub fn sanitize_toml_value<T: DeserializeOwned>(
|
|
mut value: TomlValue,
|
|
) -> io::Result<SanitizedTomlValue> {
|
|
let mut dropped_entries = Vec::new();
|
|
let mut seen_paths = HashSet::new();
|
|
|
|
loop {
|
|
let contents = toml::to_string(&value).map_err(|err| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("failed to serialize TOML while sanitizing: {err}"),
|
|
)
|
|
})?;
|
|
let deserializer = toml::de::Deserializer::parse(&contents).map_err(|err| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("failed to parse TOML while sanitizing: {err}"),
|
|
)
|
|
})?;
|
|
|
|
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
|
|
match result {
|
|
Ok(_) => {
|
|
return Ok(SanitizedTomlValue {
|
|
value,
|
|
dropped_entries,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
let path_hint = err.path().clone();
|
|
let toml_err: toml::de::Error = err.into_inner();
|
|
let Some(recovery_path) = recovery_path_for_error(&path_hint) else {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, toml_err));
|
|
};
|
|
let recovery_path_display = display_recovery_path(&recovery_path);
|
|
if !seen_paths.insert(recovery_path_display.clone()) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"unable to recover from repeated invalid TOML path {recovery_path_display}: {toml_err}",
|
|
),
|
|
));
|
|
}
|
|
if !remove_value_at_recovery_path(&mut value, &recovery_path) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"unable to remove invalid TOML path {recovery_path_display}: {toml_err}",
|
|
),
|
|
));
|
|
}
|
|
prune_empty_containers(&mut value);
|
|
dropped_entries.push(format!("{recovery_path_display}: {}", 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 {
|
|
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::SessionFlags
|
|
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum RecoveryPathSegment {
|
|
Key(String),
|
|
Index(usize),
|
|
}
|
|
|
|
fn recovery_path_for_error(path: &SerdePath) -> Option<Vec<RecoveryPathSegment>> {
|
|
let mut recovery_path = Vec::new();
|
|
let mut last_seq_recovery_len = None;
|
|
|
|
for segment in path.iter() {
|
|
match segment {
|
|
SerdeSegment::Map { key } => {
|
|
recovery_path.push(RecoveryPathSegment::Key(key.to_string()));
|
|
}
|
|
SerdeSegment::Enum { variant } => {
|
|
recovery_path.push(RecoveryPathSegment::Key(variant.to_string()));
|
|
}
|
|
SerdeSegment::Seq { index } => {
|
|
recovery_path.push(RecoveryPathSegment::Index(*index));
|
|
last_seq_recovery_len = Some(recovery_path.len());
|
|
}
|
|
SerdeSegment::Unknown => {}
|
|
}
|
|
}
|
|
|
|
if recovery_path.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(len) = last_seq_recovery_len {
|
|
recovery_path.truncate(len);
|
|
}
|
|
|
|
Some(recovery_path)
|
|
}
|
|
|
|
fn display_recovery_path(path: &[RecoveryPathSegment]) -> String {
|
|
let mut rendered = String::new();
|
|
|
|
for segment in path {
|
|
match segment {
|
|
RecoveryPathSegment::Key(key) => {
|
|
if !rendered.is_empty() {
|
|
rendered.push('.');
|
|
}
|
|
rendered.push_str(key);
|
|
}
|
|
RecoveryPathSegment::Index(index) => {
|
|
let _ = write!(rendered, "[{index}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
rendered
|
|
}
|
|
|
|
fn remove_value_at_recovery_path(value: &mut TomlValue, path: &[RecoveryPathSegment]) -> bool {
|
|
remove_value_at_recovery_path_inner(value, path, 0)
|
|
}
|
|
|
|
fn prune_empty_containers(value: &mut TomlValue) {
|
|
let _ = prune_empty_containers_inner(value);
|
|
}
|
|
|
|
fn remove_value_at_recovery_path_inner(
|
|
value: &mut TomlValue,
|
|
path: &[RecoveryPathSegment],
|
|
index: usize,
|
|
) -> bool {
|
|
let Some(segment) = path.get(index) else {
|
|
return false;
|
|
};
|
|
let is_leaf = index + 1 == path.len();
|
|
|
|
match segment {
|
|
RecoveryPathSegment::Key(key) => {
|
|
let Some(table) = value.as_table_mut() else {
|
|
return false;
|
|
};
|
|
if is_leaf {
|
|
table.remove(key).is_some()
|
|
} else {
|
|
table.get_mut(key).is_some_and(|child| {
|
|
remove_value_at_recovery_path_inner(child, path, index + 1)
|
|
})
|
|
}
|
|
}
|
|
RecoveryPathSegment::Index(seq_index) => {
|
|
let Some(array) = value.as_array_mut() else {
|
|
return false;
|
|
};
|
|
if *seq_index >= array.len() {
|
|
return false;
|
|
}
|
|
if is_leaf {
|
|
array.remove(*seq_index);
|
|
true
|
|
} else {
|
|
remove_value_at_recovery_path_inner(&mut array[*seq_index], path, index + 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn prune_empty_containers_inner(value: &mut TomlValue) -> bool {
|
|
match value {
|
|
TomlValue::Table(table) => {
|
|
table.retain(|_, child| !prune_empty_containers_inner(child));
|
|
table.is_empty()
|
|
}
|
|
TomlValue::Array(array) => {
|
|
array.retain_mut(|child| !prune_empty_containers_inner(child));
|
|
array.is_empty()
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|