use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::quote_config_value;
pub type ValueMatcher<'a> = &'a dyn Fn(Option<&str>) -> bool;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EventType {
Section,
Entry,
Comment,
Whitespace,
}
#[derive(Debug, Clone)]
struct Event {
ty: EventType,
begin: usize,
end: usize,
is_keys_section: bool,
is_key_match_section: bool,
key: Option<String>,
value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RawEditOutcome {
Changed,
NothingSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ConfigFileWriteOptions {
pub fsync: bool,
}
#[derive(Debug)]
pub enum ConfigFileWriteError {
ExistingLock(PathBuf),
Io {
path: PathBuf,
source: std::io::Error,
},
}
#[derive(Debug)]
pub enum ConfigFileEditError<E> {
Edit(E),
Write(ConfigFileWriteError),
}
impl<E: fmt::Display> fmt::Display for ConfigFileEditError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Edit(error) => error.fmt(f),
Self::Write(error) => error.fmt(f),
}
}
}
impl<E> std::error::Error for ConfigFileEditError<E>
where
E: std::error::Error + 'static,
{
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Edit(error) => Some(error),
Self::Write(error) => Some(error),
}
}
}
impl fmt::Display for ConfigFileWriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ExistingLock(path) => {
write!(f, "config lock already exists: {}", path.display())
}
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
}
}
}
impl std::error::Error for ConfigFileWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ExistingLock(_) => None,
Self::Io { source, .. } => Some(source),
}
}
}
impl ConfigFileWriteError {
fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
}
pub fn write_config_file_locked(
path: impl AsRef<Path>,
contents: &[u8],
options: ConfigFileWriteOptions,
) -> Result<(), ConfigFileWriteError> {
let path = resolve_symlink(path.as_ref());
let path = path.as_path();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|err| ConfigFileWriteError::io(parent, err))?;
}
let lock_path = config_lock_path(path)?;
let mut lock = ConfigFileLock::acquire(lock_path)?;
lock.write_all(contents, options.fsync)?;
let lock_path = lock.close();
preserve_existing_mode(path, &lock_path);
if let Err(err) = fs::rename(&lock_path, path) {
let _ = fs::remove_file(&lock_path);
return Err(ConfigFileWriteError::io(path, err));
}
Ok(())
}
pub fn edit_config_file_locked<E>(
path: impl AsRef<Path>,
options: ConfigFileWriteOptions,
edit: impl FnOnce(&[u8]) -> Result<Vec<u8>, E>,
) -> Result<(), ConfigFileEditError<E>> {
let path = resolve_symlink(path.as_ref());
let path = path.as_path();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.map_err(|error| ConfigFileEditError::Write(ConfigFileWriteError::io(parent, error)))?;
}
let lock_path = config_lock_path(path).map_err(ConfigFileEditError::Write)?;
let mut lock = ConfigFileLock::acquire(lock_path).map_err(ConfigFileEditError::Write)?;
let original = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => {
return Err(ConfigFileEditError::Write(ConfigFileWriteError::io(
path, error,
)));
}
};
let replacement = edit(&original).map_err(ConfigFileEditError::Edit)?;
lock.write_all(&replacement, options.fsync)
.map_err(ConfigFileEditError::Write)?;
let lock_path = lock.close();
preserve_existing_mode(path, &lock_path);
if let Err(error) = fs::rename(&lock_path, path) {
let _ = fs::remove_file(&lock_path);
return Err(ConfigFileEditError::Write(ConfigFileWriteError::io(
path, error,
)));
}
Ok(())
}
fn resolve_symlink(path: &Path) -> PathBuf {
const MAXDEPTH: usize = 5;
let mut current = path.to_path_buf();
for _ in 0..MAXDEPTH {
match fs::read_link(¤t) {
Ok(link) => {
if link.is_absolute() {
current = link;
} else {
current.pop();
current.push(link);
}
}
Err(_) => break,
}
}
current
}
#[cfg(unix)]
fn preserve_existing_mode(path: &Path, lock_path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let mode = meta.permissions().mode() & 0o7777;
let _ = fs::set_permissions(lock_path, fs::Permissions::from_mode(mode));
}
}
#[cfg(not(unix))]
fn preserve_existing_mode(_path: &Path, _lock_path: &Path) {}
fn config_lock_path(path: &Path) -> Result<PathBuf, ConfigFileWriteError> {
let file_name = path.file_name().ok_or_else(|| {
ConfigFileWriteError::io(
path,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config path has no filename",
),
)
})?;
let mut lock_name = file_name.to_os_string();
lock_name.push(".lock");
Ok(path.with_file_name(lock_name))
}
struct ConfigFileLock {
path: PathBuf,
file: Option<fs::File>,
active: bool,
}
impl ConfigFileLock {
fn acquire(path: PathBuf) -> Result<Self, ConfigFileWriteError> {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(file) => Ok(Self {
path,
file: Some(file),
active: true,
}),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
Err(ConfigFileWriteError::ExistingLock(path))
}
Err(err) => Err(ConfigFileWriteError::io(path, err)),
}
}
fn write_all(&mut self, contents: &[u8], fsync: bool) -> Result<(), ConfigFileWriteError> {
let Some(file) = self.file.as_mut() else {
return Err(ConfigFileWriteError::io(
&self.path,
std::io::Error::other("config lock is already closed"),
));
};
file.write_all(contents)
.map_err(|err| ConfigFileWriteError::io(&self.path, err))?;
if fsync {
file.sync_all()
.map_err(|err| ConfigFileWriteError::io(&self.path, err))?;
}
Ok(())
}
fn close(mut self) -> PathBuf {
self.active = false;
let _ = self.file.take();
self.path.clone()
}
}
impl Drop for ConfigFileLock {
fn drop(&mut self) {
if self.active {
let _ = self.file.take();
let _ = fs::remove_file(&self.path);
}
}
}
pub struct RawConfigEditor {
contents: Vec<u8>,
events: Vec<Event>,
section: String,
subsection: Option<String>,
name: String,
name_as_typed: String,
}
impl RawConfigEditor {
pub fn new(contents: Vec<u8>, section: &str, subsection: Option<&str>, name: &str) -> Self {
let mut editor = Self {
contents,
events: Vec::new(),
section: section.to_string(),
subsection: subsection.map(str::to_string),
name: name.to_ascii_lowercase(),
name_as_typed: name.to_string(),
};
editor.parse_events();
editor
}
fn parse_events(&mut self) {
let bytes = self.contents.clone();
let len = bytes.len();
let mut i = 0usize;
let mut cur_is_keys_section = false;
let mut cur_is_key_match_section = false;
if bytes.starts_with(b"\xEF\xBB\xBF") {
i = 3;
}
while i < len {
let c = bytes[i];
if c == b'\n' {
self.push_ws(i, i + 1);
i += 1;
continue;
}
if c == b' ' || c == b'\t' || c == b'\r' {
let begin = i;
while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
i += 1;
}
self.push_ws(begin, i);
continue;
}
if c == b'#' || c == b';' {
let begin = i;
while i < len && bytes[i] != b'\n' {
i += 1;
}
self.events.push(Event {
ty: EventType::Comment,
begin,
end: i,
is_keys_section: false,
is_key_match_section: false,
key: None,
value: None,
});
continue;
}
if c == b'[' {
let begin = i;
let (section, sub, subsection_case_sensitive, next) =
parse_section_header(&bytes, i);
i = next;
let is_keys = section.as_ref().is_some_and(|s| {
self.section_matches(s, sub.as_deref(), subsection_case_sensitive)
});
let is_key_match = section
.as_ref()
.is_some_and(|s| self.section_exact_matches(s, sub.as_deref()));
cur_is_keys_section = is_keys;
cur_is_key_match_section = is_key_match;
self.events.push(Event {
ty: EventType::Section,
begin,
end: i,
is_keys_section: is_keys,
is_key_match_section: is_key_match,
key: None,
value: None,
});
continue;
}
if c.is_ascii_alphabetic() {
let begin = i;
let (key, value, next) = parse_entry(&bytes, i);
i = next;
self.events.push(Event {
ty: EventType::Entry,
begin,
end: i,
is_keys_section: cur_is_keys_section,
is_key_match_section: cur_is_key_match_section,
key,
value,
});
continue;
}
let begin = i;
while i < len && bytes[i] != b'\n' {
i += 1;
}
self.push_ws(begin, i);
}
}
fn push_ws(&mut self, begin: usize, end: usize) {
if begin >= end {
return;
}
if let Some(last) = self.events.last_mut()
&& last.ty == EventType::Whitespace
&& last.end == begin
{
last.end = end;
return;
}
self.events.push(Event {
ty: EventType::Whitespace,
begin,
end,
is_keys_section: false,
is_key_match_section: false,
key: None,
value: None,
});
}
fn section_matches(
&self,
section: &str,
subsection: Option<&str>,
subsection_case_sensitive: bool,
) -> bool {
if !section.eq_ignore_ascii_case(&self.section) {
return false;
}
match (self.subsection.as_deref(), subsection) {
(None, None) => true,
(Some(a), Some(b)) => {
if subsection_case_sensitive {
a == b
} else {
a.eq_ignore_ascii_case(b)
}
}
_ => false,
}
}
fn section_exact_matches(&self, section: &str, subsection: Option<&str>) -> bool {
if !section.eq_ignore_ascii_case(&self.section) {
return false;
}
match (self.subsection.as_deref(), subsection) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
_ => false,
}
}
pub fn set_multivar(
&mut self,
value: Option<&str>,
comment: Option<&str>,
value_matches: Option<ValueMatcher>,
multi_replace: bool,
) -> RawEditOutcome {
let mut seen: Vec<usize> = Vec::new();
let mut seen_nr = 0usize;
let mut key_seen = false;
let mut section_seen = false;
let set_slot = |seen: &mut Vec<usize>, slot: usize, idx: usize| {
if seen.len() <= slot {
seen.resize(slot + 1, 0);
}
seen[slot] = idx;
};
for (idx, ev) in self.events.iter().enumerate() {
match ev.ty {
EventType::Section if ev.is_keys_section => {
section_seen = true;
set_slot(&mut seen, seen_nr, idx);
}
EventType::Entry => {
if key_seen {
if self.entry_matches(ev, value_matches) {
set_slot(&mut seen, seen_nr, idx);
seen_nr += 1;
}
} else if ev.is_keys_section {
set_slot(&mut seen, seen_nr, idx);
section_seen = true;
if self.entry_matches(ev, value_matches) {
seen_nr += 1;
key_seen = true;
}
}
}
_ => {}
}
}
if (seen_nr == 0 && value.is_none()) || (seen_nr > 1 && !multi_replace) {
return RawEditOutcome::NothingSet;
}
if seen_nr == 0 {
if seen.is_empty()
&& let Some(last) = self.events.len().checked_sub(1)
{
seen.push(last);
}
seen_nr = 1;
}
let seen_indices: Vec<usize> = seen.into_iter().take(seen_nr).collect();
self.splice(&seen_indices, key_seen, section_seen, value, comment);
RawEditOutcome::Changed
}
fn entry_matches(&self, ev: &Event, value_matches: Option<ValueMatcher>) -> bool {
if !ev.is_key_match_section || ev.key.as_deref() != Some(self.name.as_str()) {
return false;
}
match value_matches {
None => true,
Some(pred) => pred(ev.value.as_deref()),
}
}
fn splice(
&mut self,
seen: &[usize],
key_seen: bool,
section_seen: bool,
value: Option<&str>,
comment: Option<&str>,
) {
let contents = self.contents.clone();
let contents_sz = contents.len();
let mut out: Vec<u8> = Vec::with_capacity(contents_sz + 64);
let mut copy_begin = 0usize;
let mut i = 0usize;
while i < seen.len() {
let j = seen[i];
let mut new_line = false;
let copy_end;
let replace_end;
if !key_seen {
let mut ce = self.events[j].end;
if ce > 0 && ce < contents_sz && contents[ce - 1] != b'\n' && contents[ce] == b'\n'
{
ce += 1;
}
copy_end = ce;
replace_end = ce;
} else {
let mut re = self.events[j].end;
let mut ce = self.events[j].begin;
if value.is_none() {
let (nb, ne) = self.maybe_remove_section(seen, &mut i, ce, re);
ce = nb;
re = ne;
}
while ce > 0 {
let ch = contents[ce - 1];
if (ch == b' ' || ch == b'\t' || ch == b'\r') && ch != b'\n' {
ce -= 1;
} else {
break;
}
}
copy_end = ce;
replace_end = re;
}
if copy_end > 0 && contents[copy_end - 1] != b'\n' {
new_line = true;
}
if copy_end > copy_begin {
out.extend_from_slice(&contents[copy_begin..copy_end]);
if new_line {
out.push(b'\n');
}
}
copy_begin = replace_end;
i += 1;
}
if let Some(value) = value {
if !section_seen {
write_section(&mut out, &self.section, self.subsection.as_deref());
}
write_pair(&mut out, &self.name_as_typed, value, comment);
}
if copy_begin < contents_sz {
out.extend_from_slice(&contents[copy_begin..contents_sz]);
}
self.contents = out;
}
fn maybe_remove_section(
&self,
seen: &[usize],
seen_ptr: &mut usize,
begin_in: usize,
end_in: usize,
) -> (usize, usize) {
let parsed = &self.events;
let parsed_nr = parsed.len();
let mut seen_idx = *seen_ptr;
let mut section_seen = false;
let mut i = seen[seen_idx];
while i > 0 {
let ty = parsed[i - 1].ty;
match ty {
EventType::Comment => return (begin_in, end_in),
EventType::Entry => {
if !section_seen {
return (begin_in, end_in);
}
break;
}
EventType::Section => {
if !parsed[i - 1].is_keys_section {
break;
}
section_seen = true;
i -= 1;
}
EventType::Whitespace => {
i -= 1;
}
}
}
let begin = parsed[i].begin;
let mut k = seen[seen_idx] + 1;
while k < parsed_nr {
let ty = parsed[k].ty;
match ty {
EventType::Comment => return (begin_in, end_in),
EventType::Section => {
if parsed[k].is_keys_section {
k += 1;
continue;
}
break;
}
EventType::Entry => {
seen_idx += 1;
if seen_idx < seen.len() && k == seen[seen_idx] {
k += 1;
continue;
}
return (begin_in, end_in);
}
EventType::Whitespace => {
k += 1;
}
}
}
*seen_ptr = seen_idx;
let end = if k < parsed_nr {
parsed[k].begin
} else {
parsed[parsed_nr - 1].end
};
(begin, end)
}
pub fn into_bytes(self) -> Vec<u8> {
self.contents
}
}
const GIT_CONFIG_MAX_LINE_LEN: usize = 512 * 1024;
pub enum SectionEditOutcome {
Changed(Vec<u8>),
NotFound,
LineTooLong(usize),
}
pub fn rename_or_remove_section(
contents: &[u8],
old_name: &str,
new_name: Option<&str>,
) -> SectionEditOutcome {
let mut out: Vec<u8> = Vec::with_capacity(contents.len() + 16);
let mut matched = 0usize;
let mut removing = false;
let mut line_nr = 0usize;
let mut pos = 0usize;
let len = contents.len();
while pos < len {
let line_start = pos;
while pos < len && contents[pos] != b'\n' {
pos += 1;
}
if pos < len {
pos += 1; }
let line = &contents[line_start..pos];
line_nr += 1;
if line.len() >= GIT_CONFIG_MAX_LINE_LEN {
return SectionEditOutcome::LineTooLong(line_nr);
}
let mut i = 0usize;
while i < line.len() && (line[i] as char).is_whitespace() {
i += 1;
}
if i < line.len() && line[i] == b'[' {
let offset = section_name_match(&line[i..], old_name);
if offset > 0 {
matched += 1;
if let Some(new_name) = new_name {
write_section_normalised(&mut out, new_name);
let consumed = i + offset;
if consumed < line.len() {
out.push(b'\t');
out.extend_from_slice(&line[consumed..]);
}
removing = false;
continue;
} else {
removing = true;
continue;
}
}
removing = false;
}
if removing {
continue;
}
out.extend_from_slice(line);
}
if matched == 0 {
SectionEditOutcome::NotFound
} else {
SectionEditOutcome::Changed(out)
}
}
fn section_name_match(buf: &[u8], name: &str) -> usize {
let nb = name.as_bytes();
let gb = |k: usize| -> u8 { *buf.get(k).unwrap_or(&0) };
let gn = |k: usize| -> u8 { *nb.get(k).unwrap_or(&0) };
if gb(0) != b'[' {
return 0;
}
let mut i = 1usize;
let mut j = 0usize;
let mut dot = false;
let is_space =
|c: u8| c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' || c == 0x0c || c == 0x0b;
'outer: while gb(i) != 0 && gb(i) != b']' {
let mut did_continue = false;
if !dot && is_space(gb(i)) {
dot = true;
let nj = gn(j);
j += 1;
if nj != b'.' {
break;
}
i += 1;
while is_space(gb(i)) {
i += 1;
}
if gb(i) != b'"' {
break;
}
did_continue = true;
}
if !did_continue {
if gb(i) == b'\\' && dot {
i += 1;
} else if gb(i) == b'"' && dot {
i += 1;
while is_space(gb(i)) {
i += 1;
}
break 'outer;
}
let bc = gb(i);
let nc = gn(j);
j += 1;
if bc != nc {
break 'outer;
}
}
i += 1; }
if gb(i) == b']' && gn(j) == 0 {
i += 1;
while gb(i) != 0 && is_space(gb(i)) {
i += 1;
}
return i;
}
0
}
fn write_section_normalised(out: &mut Vec<u8>, name: &str) {
out.push(b'[');
if let Some((section, subsection)) = name.split_once('.') {
out.extend_from_slice(section.as_bytes());
out.extend_from_slice(b" \"");
for ch in subsection.chars() {
if ch == '"' || ch == '\\' {
out.push(b'\\');
}
let mut b = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut b).as_bytes());
}
out.push(b'"');
} else {
out.extend_from_slice(name.as_bytes());
}
out.extend_from_slice(b"]\n");
}
fn write_pair(out: &mut Vec<u8>, name: &str, value: &str, comment: Option<&str>) {
out.push(b'\t');
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b" = ");
out.extend_from_slice(quote_config_value(value).as_bytes());
if let Some(comment) = comment {
out.extend_from_slice(comment.as_bytes());
}
out.push(b'\n');
}
fn write_section(out: &mut Vec<u8>, section: &str, subsection: Option<&str>) {
out.push(b'[');
out.extend_from_slice(section.as_bytes());
if let Some(sub) = subsection {
out.extend_from_slice(b" \"");
for ch in sub.chars() {
if ch == '"' || ch == '\\' {
out.push(b'\\');
}
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
out.push(b'"');
}
out.extend_from_slice(b"]\n");
}
fn parse_section_header(
bytes: &[u8],
start: usize,
) -> (Option<String>, Option<String>, bool, usize) {
let len = bytes.len();
let mut i = start + 1; let name_start = i;
while i < len {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'-' || c == b'.' {
i += 1;
} else {
break;
}
}
let raw_name = String::from_utf8_lossy(&bytes[name_start..i]).into_owned();
if let Some((head, rest)) = raw_name.split_once('.') {
while i < len && bytes[i] != b']' && bytes[i] != b'\n' {
i += 1;
}
if i < len && bytes[i] == b']' {
i += 1;
}
return (
Some(head.to_ascii_lowercase()),
Some(rest.to_ascii_lowercase()),
false,
i,
);
}
while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
i += 1;
}
let mut subsection = None;
if i < len && bytes[i] == b'"' {
i += 1;
let mut sub = String::new();
while i < len {
match bytes[i] {
b'"' => {
i += 1;
break;
}
b'\\' if i + 1 < len => {
i += 1;
sub.push(bytes[i] as char);
i += 1;
}
b'\n' => break,
other => {
sub.push(other as char);
i += 1;
}
}
}
subsection = Some(sub);
while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
i += 1;
}
}
if i < len && bytes[i] == b']' {
i += 1;
} else {
while i < len && bytes[i] != b'\n' {
i += 1;
}
return (None, None, true, i);
}
(Some(raw_name), subsection, true, i)
}
fn parse_entry(bytes: &[u8], start: usize) -> (Option<String>, Option<String>, usize) {
let len = bytes.len();
let mut i = start;
let key_start = i;
while i < len {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'-' {
i += 1;
} else {
break;
}
}
let key = String::from_utf8_lossy(&bytes[key_start..i]).to_ascii_lowercase();
while i < len && matches!(bytes[i], b' ' | b'\t' | b'\r') {
i += 1;
}
if i >= len || bytes[i] == b'\n' {
if i < len && bytes[i] == b'\n' {
i += 1;
}
return (Some(key), None, i);
}
if bytes[i] != b'=' {
while i < len && bytes[i] != b'\n' {
i += 1;
}
if i < len {
i += 1;
}
return (Some(key), None, i);
}
i += 1; let (value, next) = parse_value_span(bytes, i);
(Some(key), Some(value), next)
}
fn parse_value_span(bytes: &[u8], start: usize) -> (String, usize) {
let len = bytes.len();
let mut i = start;
let mut out = String::new();
let mut trailing_ws = 0usize;
let mut leading = true;
let mut in_quotes = false;
while i < len {
let c = bytes[i];
match c {
b'\n' if !in_quotes => {
i += 1;
break;
}
b'"' => {
i += 1;
in_quotes = !in_quotes;
leading = false;
}
b'\\' => {
i += 1;
if i >= len {
break;
}
let e = bytes[i];
i += 1;
match e {
b'\n' => {} b'\r' if i < len && bytes[i] == b'\n' => {
i += 1;
}
b'n' => {
out.push('\n');
trailing_ws = 0;
leading = false;
}
b't' => {
out.push('\t');
trailing_ws = 0;
leading = false;
}
b'b' => {
out.push('\u{0008}');
trailing_ws = 0;
leading = false;
}
b'"' => {
out.push('"');
trailing_ws = 0;
leading = false;
}
b'\\' => {
out.push('\\');
trailing_ws = 0;
leading = false;
}
_ => {
leading = false;
}
}
}
b'#' | b';' if !in_quotes => {
while i < len && bytes[i] != b'\n' {
i += 1;
}
if i < len {
i += 1;
}
break;
}
b' ' | b'\t' if !in_quotes => {
i += 1;
if leading {
} else {
out.push(c as char);
trailing_ws += 1;
}
}
b'\r' if !in_quotes => {
i += 1;
if i < len && bytes[i] == b'\n' {
} else if !leading {
out.push('\r');
trailing_ws = 0;
leading = false;
}
}
other => {
i += 1;
out.push(other as char);
trailing_ws = 0;
leading = false;
}
}
}
out.truncate(out.len().saturating_sub(trailing_ws));
(out, i)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::too_many_arguments)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"sley-config-raw-edit-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn edit(
src: &str,
sec: &str,
sub: Option<&str>,
name: &str,
value: Option<&str>,
comment: Option<&str>,
vm: Option<ValueMatcher>,
multi: bool,
) -> (String, RawEditOutcome) {
let mut e = RawConfigEditor::new(src.as_bytes().to_vec(), sec, sub, name);
let out = e.set_multivar(value, comment, vm, multi);
(String::from_utf8(e.into_bytes()).unwrap(), out)
}
#[test]
fn write_config_file_locked_writes_and_cleans_lock() {
let temp = TempDir::new();
let path = temp.path.join("nested").join("config");
write_config_file_locked(
&path,
b"[user]\n\tname = Ada\n",
ConfigFileWriteOptions::default(),
)
.expect("write config");
assert_eq!(
fs::read(&path).expect("read config"),
b"[user]\n\tname = Ada\n"
);
assert!(!path.with_file_name("config.lock").exists());
}
#[test]
fn edit_config_file_locked_reads_each_serialized_predecessor_under_lock() {
let temp = TempDir::new();
let path = temp.path.join("config");
for line in [b"one = 1\n".as_slice(), b"two = 2\n".as_slice()] {
edit_config_file_locked(&path, ConfigFileWriteOptions::default(), |original| {
assert!(
path.with_file_name("config.lock").exists(),
"the callback must run while the config lock is held"
);
let mut replacement = original.to_vec();
replacement.extend_from_slice(line);
Ok::<_, std::convert::Infallible>(replacement)
})
.expect("serialized config edit");
}
assert_eq!(
fs::read(&path).expect("read edited config"),
b"one = 1\ntwo = 2\n"
);
assert!(!path.with_file_name("config.lock").exists());
}
#[cfg(unix)]
#[test]
fn write_config_file_locked_follows_symlink_and_keeps_it() {
let temp = TempDir::new();
let real = temp.path.join("notyet");
let link = temp.path.join("myconfig");
std::os::unix::fs::symlink("notyet", &link).expect("create symlink");
write_config_file_locked(
&link,
b"[test]\n\tfrotz = nitfol\n",
ConfigFileWriteOptions::default(),
)
.expect("write through symlink");
assert!(
fs::symlink_metadata(&link)
.expect("stat link")
.file_type()
.is_symlink(),
"myconfig must remain a symlink"
);
assert!(
fs::symlink_metadata(&real)
.expect("stat real")
.file_type()
.is_file(),
"notyet must be a regular file"
);
assert_eq!(
fs::read(&real).expect("read real"),
b"[test]\n\tfrotz = nitfol\n"
);
assert!(!real.with_file_name("notyet.lock").exists());
assert!(!link.with_file_name("myconfig.lock").exists());
}
#[cfg(unix)]
#[test]
fn write_config_file_locked_preserves_existing_mode() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new();
let path = temp.path.join("config");
fs::write(&path, b"[user]\n\tname = Old\n").expect("write original");
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod 0600");
write_config_file_locked(
&path,
b"[user]\n\tname = New\n",
ConfigFileWriteOptions::default(),
)
.expect("rewrite config");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o600, "mode must be preserved across the edit");
assert_eq!(fs::read(&path).expect("read"), b"[user]\n\tname = New\n");
}
#[test]
fn write_config_file_locked_existing_lock_preserves_original() {
let temp = TempDir::new();
let path = temp.path.join("config");
let lock_path = path.with_file_name("config.lock");
fs::write(&path, b"[user]\n\tname = Old\n").expect("write original");
fs::write(&lock_path, b"held\n").expect("write lock");
let err = write_config_file_locked(
&path,
b"[user]\n\tname = New\n",
ConfigFileWriteOptions::default(),
)
.expect_err("held lock must fail");
assert!(matches!(err, ConfigFileWriteError::ExistingLock(_)));
assert_eq!(
fs::read(&path).expect("read original"),
b"[user]\n\tname = Old\n"
);
assert_eq!(fs::read(&lock_path).expect("read lock"), b"held\n");
}
#[test]
fn unset_cont_lines_preserves_layout() {
let src = "[alpha]\nbar = foo\n[beta]\nbaz = multiple \\\nlines\nfoo = bar\n";
let (out, _) = edit(src, "beta", None, "baz", None, None, None, true);
assert_eq!(out, "[alpha]\nbar = foo\n[beta]\nfoo = bar\n");
}
#[test]
fn unset_all_silly_comments_preserved() {
let src = "[beta] ; silly comment # another comment\nnoIndent= sillyValue ; 'nother silly comment\n\n# empty line\n\t\t; comment\n\t\thaha =\"beta\" # last silly comment\nhaha = hello\n\thaha = bello\n[nextSection] noNewline = ouch\n";
let (out, _) = edit(src, "beta", None, "haha", None, None, None, true);
let expect = "[beta] ; silly comment # another comment\nnoIndent= sillyValue ; 'nother silly comment\n\n# empty line\n\t\t; comment\n[nextSection] noNewline = ouch\n";
assert_eq!(out, expect);
}
#[test]
fn replace_all_preserves_other_lines() {
let src = "[beta] ; silly comment # another comment\nnoIndent= sillyValue ; 'nother silly comment\n\n# empty line\n\t\t; comment\n\t\thaha =\"beta\" # last silly comment\nhaha = hello\n\thaha = bello\n[nextSection] noNewline = ouch\n";
let (out, _) = edit(src, "beta", None, "haha", Some("gamma"), None, None, true);
let expect = "[beta] ; silly comment # another comment\nnoIndent= sillyValue ; 'nother silly comment\n\n# empty line\n\t\t; comment\n\thaha = gamma\n[nextSection] noNewline = ouch\n";
assert_eq!(out, expect);
}
#[test]
fn replace_all_does_not_touch_same_name_in_other_section() {
let src = "[abc]key\n\tkeepSection\n[xyz]\n\tkey = 1\n[abc]\n\tkey = a\n";
let (out, _) = edit(src, "abc", None, "key", Some("b"), None, None, true);
let expect = "[abc]\n\tkeepSection\n[xyz]\n\tkey = 1\n[abc]\n\tkey = b\n";
assert_eq!(out, expect);
}
#[test]
fn set_uses_case_compatible_dotted_subsection_for_insert() {
let src = "[V.A]\n\tx = old\n";
let (out, outcome) = edit(src, "V", Some("A"), "r", Some("new"), None, None, false);
assert_eq!(outcome, RawEditOutcome::Changed);
assert_eq!(out, "[V.A]\n\tx = old\n\tr = new\n");
}
#[test]
fn set_keeps_dotted_subsection_exactness_for_replacement() {
let src = "[V.A]\n\tr = old\n";
let (out, outcome) = edit(src, "v", Some("a"), "r", Some("new"), None, None, false);
assert_eq!(outcome, RawEditOutcome::Changed);
assert_eq!(out, "[V.A]\n\tr = new\n");
let (out, outcome) = edit(src, "V", Some("A"), "r", Some("new"), None, None, false);
assert_eq!(outcome, RawEditOutcome::Changed);
assert_eq!(out, "[V.A]\n\tr = old\n\tr = new\n");
}
#[test]
fn quoted_subsection_stays_case_sensitive_for_insert() {
let src = "[V \"a\"]\n\tx = old\n";
let (out, outcome) = edit(src, "V", Some("A"), "r", Some("new"), None, None, false);
assert_eq!(outcome, RawEditOutcome::Changed);
assert_eq!(out, "[V \"a\"]\n\tx = old\n[V \"A\"]\n\tr = new\n");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod section_tests {
use super::*;
fn rr(src: &str, old: &str, new: Option<&str>) -> SectionEditOutcome {
rename_or_remove_section(src.as_bytes(), old, new)
}
#[test]
fn rename_quoted_and_dotted_forms() {
let src = "# Hallo\n\t#Bello\n[branch \"eins\"]\n\tx = 1\n[branch.eins]\n\ty = 1\n\t[branch \"1 234 blabl/a\"]\nweird\n";
let SectionEditOutcome::Changed(out) = rr(src, "branch.eins", Some("branch.zwei")) else {
panic!("expected Changed");
};
let expect = "# Hallo\n\t#Bello\n[branch \"zwei\"]\n\tx = 1\n[branch \"zwei\"]\n\ty = 1\n\t[branch \"1 234 blabl/a\"]\nweird\n";
assert_eq!(String::from_utf8(out).unwrap(), expect);
}
#[test]
fn rename_inline_var_indents_remainder() {
let src = "[branch \"vier\"] z = 1\n";
let SectionEditOutcome::Changed(out) = rr(src, "branch.vier", Some("branch.zwei")) else {
panic!("expected Changed");
};
assert_eq!(
String::from_utf8(out).unwrap(),
"[branch \"zwei\"]\n\tz = 1\n"
);
}
#[test]
fn remove_section_drops_lines() {
let src = "[a]\n\tx = 1\n[b]\n\ty = 2\n";
let SectionEditOutcome::Changed(out) = rr(src, "a", None) else {
panic!("expected Changed");
};
assert_eq!(String::from_utf8(out).unwrap(), "[b]\n\ty = 2\n");
}
#[test]
fn rename_nonexistent_is_not_found() {
let src = "[a]\n\tx = 1\n";
assert!(matches!(
rr(src, "zzz", Some("q.r")),
SectionEditOutcome::NotFound
));
}
}