use std::path::{Path, PathBuf};
pub fn autofix_config_files(paths: &[PathBuf]) -> bool {
let mut repaired_any = false;
for path in paths {
if !path.exists() {
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Auto-fix: cannot read {:?}: {e}", path);
continue;
}
};
let Some((fixed, fixes)) = repair_toml(&content) else {
continue;
};
if save_repaired(path, &fixed, &fixes) {
repaired_any = true;
}
}
repaired_any
}
fn save_repaired(path: &Path, fixed: &str, fixes: &[String]) -> bool {
let backup = path.with_extension("toml.autofix.bak");
if let Err(e) = std::fs::copy(path, &backup) {
tracing::warn!("Auto-fix: failed to back up {:?}: {e}", path);
}
match std::fs::write(path, fixed) {
Ok(()) => {
for f in fixes {
tracing::warn!("Auto-fixed {:?}: {f}", path);
}
tracing::warn!(
"Auto-fixed {} config syntax error(s) in {:?}; original saved to {:?}",
fixes.len(),
path,
backup
);
true
}
Err(e) => {
tracing::error!("Auto-fix: failed to write repaired {:?}: {e}", path);
false
}
}
}
pub fn repair_toml(content: &str) -> Option<(String, Vec<String>)> {
if toml::from_str::<toml::Value>(content).is_ok() {
return None;
}
let mut fixes = Vec::new();
let fixed = balance_delimiters(content, &mut fixes)?;
if fixes.is_empty() {
return None;
}
match toml::from_str::<toml::Value>(&fixed) {
Ok(_) => Some((fixed, fixes)),
Err(_) => None,
}
}
fn balance_delimiters(content: &str, fixes: &mut Vec<String>) -> Option<String> {
let mut out = String::with_capacity(content.len() + 8);
let mut stack: Vec<char> = Vec::new();
for line in content.split_inclusive('\n') {
let (body, nl) = match line.strip_suffix('\n') {
Some(b) => (b, "\n"),
None => (line, ""),
};
let trimmed = body.trim_start();
if !stack.is_empty() && starts_new_item(trimmed) {
let mut closers = String::new();
while let Some(open) = stack.pop() {
if open == '{' {
return None; }
closers.push(']');
fixes.push("inserted ']' to close an unterminated array".to_string());
}
out.push_str(&closers);
out.push('\n');
}
if stack.is_empty() && is_header_line(trimmed) {
out.push_str(body);
out.push_str(nl);
continue;
}
scan_value_line(body, &mut stack);
out.push_str(body);
out.push_str(nl);
}
while let Some(open) = stack.pop() {
if open == '{' {
return None; }
out.push(']');
fixes.push("inserted ']' at end of file to close an unterminated array".to_string());
}
Some(out)
}
fn starts_new_item(trimmed: &str) -> bool {
is_header_line(trimmed) || starts_top_level_key(trimmed)
}
fn is_header_line(trimmed: &str) -> bool {
let s = trimmed.trim_end();
let s = match s.find('#') {
Some(i) => s[..i].trim_end(),
None => s,
};
if !s.starts_with('[') || !s.ends_with(']') {
return false;
}
let double = s.starts_with("[[") && s.ends_with("]]");
let inner = if double {
&s[2..s.len() - 2]
} else {
&s[1..s.len() - 1]
};
if inner.is_empty() {
return false;
}
inner
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '"' | '\'' | ' '))
&& !inner.contains(',')
}
fn starts_top_level_key(trimmed: &str) -> bool {
let mut chars = trimmed.chars().peekable();
let first = match chars.peek() {
Some(&c) => c,
None => return false,
};
if !(first.is_ascii_alphanumeric() || matches!(first, '_' | '-' | '"' | '\'')) {
return false;
}
let mut in_q: Option<char> = None;
for c in trimmed.chars() {
match in_q {
Some(q) => {
if c == q {
in_q = None;
}
}
None => match c {
'"' | '\'' => in_q = Some(c),
'=' => return true,
'[' | '{' | '#' => return false,
_ => {}
},
}
}
false
}
fn scan_value_line(body: &str, stack: &mut Vec<char>) {
let mut in_basic = false;
let mut in_literal = false;
let mut escaped = false;
for c in body.chars() {
if in_basic {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_basic = false;
}
continue;
}
if in_literal {
if c == '\'' {
in_literal = false;
}
continue;
}
match c {
'#' => break,
'"' => in_basic = true,
'\'' => in_literal = true,
'[' | '{' => stack.push(c),
']' if stack.last() == Some(&'[') => {
stack.pop();
}
'}' if stack.last() == Some(&'{') => {
stack.pop();
}
_ => {}
}
}
}