1use 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#[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 first_layer_config_error_for_entries::<T, _>(
179 layers.get_layers(
180 ConfigLayerStackOrdering::LowestPrecedenceFirst,
181 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 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}