use std::collections::BTreeMap;
use crate::transport::{SharedTransport, TransportError};
mod configobj;
mod option;
pub use configobj::{ConfigObj, ConfigObjError};
pub use option::{
bool_from_store, int_from_store, int_si_from_store, list_from_store, Option as ConfigOption,
OptionRegistry,
};
#[derive(Debug)]
pub enum ConfigError {
Parse(ConfigObjError),
Transport(TransportError),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Parse(e) => write!(f, "config parse error: {e}"),
ConfigError::Transport(e) => write!(f, "transport error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
impl From<ConfigObjError> for ConfigError {
fn from(e: ConfigObjError) -> Self {
ConfigError::Parse(e)
}
}
impl From<TransportError> for ConfigError {
fn from(e: TransportError) -> Self {
ConfigError::Transport(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
id: Option<String>,
options: BTreeMap<String, String>,
order: Vec<String>,
}
impl Section {
fn new(id: Option<String>, options: Vec<(String, String)>) -> Self {
let mut map = BTreeMap::new();
let mut order = Vec::with_capacity(options.len());
for (k, v) in options {
if !map.contains_key(&k) {
order.push(k.clone());
}
map.insert(k, v);
}
Section {
id,
options: map,
order,
}
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn get(&self, name: &str) -> Option<&str> {
self.options.get(name).map(|s| s.as_str())
}
pub fn iter_option_names(&self) -> impl Iterator<Item = &str> {
self.order.iter().map(|s| s.as_str())
}
}
#[derive(Debug, Clone)]
pub struct MutableSection {
section: Section,
dirty: BTreeMap<String, bool>,
}
impl MutableSection {
fn new(section: Section) -> Self {
MutableSection {
section,
dirty: BTreeMap::new(),
}
}
pub fn id(&self) -> Option<&str> {
self.section.id()
}
pub fn get(&self, name: &str) -> Option<&str> {
self.section.get(name)
}
pub fn set(&mut self, name: &str, value: &str) {
if !self.section.options.contains_key(name) {
self.section.order.push(name.to_string());
}
self.section
.options
.insert(name.to_string(), value.to_string());
self.dirty.insert(name.to_string(), false);
}
pub fn remove(&mut self, name: &str) {
if self.section.options.remove(name).is_some() {
self.section.order.retain(|k| k != name);
self.dirty.insert(name.to_string(), true);
}
}
pub fn is_dirty(&self) -> bool {
!self.dirty.is_empty()
}
}
pub struct IniFileStore {
config_obj: Option<ConfigObj>,
}
impl Default for IniFileStore {
fn default() -> Self {
Self::new()
}
}
impl IniFileStore {
pub fn new() -> Self {
IniFileStore { config_obj: None }
}
pub fn is_loaded(&self) -> bool {
self.config_obj.is_some()
}
pub fn load_from_bytes(&mut self, bytes: &[u8]) -> Result<(), ConfigError> {
self.config_obj = Some(ConfigObj::parse(bytes)?);
Ok(())
}
pub fn to_bytes(&self) -> Vec<u8> {
match &self.config_obj {
Some(c) => c.to_bytes(),
None => Vec::new(),
}
}
pub fn get_sections(&self) -> Vec<Section> {
match &self.config_obj {
Some(c) => c.sections(),
None => Vec::new(),
}
}
pub fn get_mutable_section(&mut self, section_id: Option<&str>) -> MutableSection {
let config = self.config_obj.get_or_insert_with(ConfigObj::empty);
let section = config
.section(section_id)
.unwrap_or_else(|| Section::new(section_id.map(|s| s.to_string()), Vec::new()));
MutableSection::new(section)
}
pub fn apply_changes(&mut self, section: &MutableSection) {
let config = self.config_obj.get_or_insert_with(ConfigObj::empty);
for (key, removed) in §ion.dirty {
if *removed {
config.remove_value(section.id(), key);
} else if let Some(value) = section.get(key) {
config.set_value(section.id(), key, value);
}
}
}
pub fn quote(&self, value: &str) -> String {
configobj::quote_value(value)
}
pub fn unquote(&self, value: &str) -> String {
configobj::unquote_value(value)
}
}
pub struct TransportIniFileStore {
store: IniFileStore,
transport: SharedTransport,
file_name: String,
}
impl TransportIniFileStore {
pub fn new(transport: SharedTransport, file_name: impl Into<String>) -> Self {
TransportIniFileStore {
store: IniFileStore::new(),
transport,
file_name: file_name.into(),
}
}
pub fn load(&mut self) -> Result<(), ConfigError> {
if self.store.is_loaded() {
return Ok(());
}
let bytes = match self.transport.get_bytes(&self.file_name) {
Ok(b) => b,
Err(TransportError::NoSuchFile(_)) => Vec::new(),
Err(e) => return Err(e.into()),
};
self.store.load_from_bytes(&bytes)
}
pub fn save(&self) -> Result<(), ConfigError> {
if !self.store.is_loaded() {
return Ok(());
}
self.transport
.put_bytes(&self.file_name, &self.store.to_bytes(), None)?;
Ok(())
}
pub fn store(&self) -> &IniFileStore {
&self.store
}
pub fn store_mut(&mut self) -> &mut IniFileStore {
&mut self.store
}
}
pub struct Stack<'r> {
sections: Vec<Section>,
registry: &'r OptionRegistry,
}
impl<'r> Stack<'r> {
pub fn new(sections: Vec<Section>, registry: &'r OptionRegistry) -> Self {
Stack { sections, registry }
}
fn get_raw(&self, name: &str) -> Option<&str> {
self.sections.iter().find_map(|s| s.get(name))
}
pub fn get(&self, name: &str) -> Option<String> {
let opt = self.registry.get(name);
if let Some(raw) = self.get_raw(name) {
let unquoted = configobj::unquote_value(raw);
return match opt {
Some(o) => o.convert_from_unicode(&unquoted),
None => Some(unquoted),
};
}
opt.and_then(|o| o.default().map(|s| s.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::{LocalTransport, Transport};
use std::sync::Arc;
#[test]
fn ini_file_store_round_trips_no_name_section() {
let mut store = IniFileStore::new();
store
.load_from_bytes(b"stacked_on_location = ../parent\nnickname = trunk\n")
.unwrap();
let sections = store.get_sections();
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].id(), None);
assert_eq!(sections[0].get("stacked_on_location"), Some("../parent"));
assert_eq!(sections[0].get("nickname"), Some("trunk"));
}
#[test]
fn set_value_preserves_other_keys() {
let mut store = IniFileStore::new();
store.load_from_bytes(b"nickname = trunk\n").unwrap();
let mut sec = store.get_mutable_section(None);
sec.set("bound", "True");
store.apply_changes(&sec);
let reloaded = store.get_sections();
assert_eq!(reloaded[0].get("nickname"), Some("trunk"));
assert_eq!(reloaded[0].get("bound"), Some("True"));
}
#[test]
fn remove_value_drops_key() {
let mut store = IniFileStore::new();
store
.load_from_bytes(b"nickname = trunk\nbound = True\n")
.unwrap();
let mut sec = store.get_mutable_section(None);
sec.remove("bound");
store.apply_changes(&sec);
assert_eq!(store.get_sections()[0].get("bound"), None);
assert_eq!(store.get_sections()[0].get("nickname"), Some("trunk"));
}
#[test]
fn transport_store_loads_missing_file_as_empty() {
let dir = tempfile::tempdir().unwrap();
let transport: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
let mut store = TransportIniFileStore::new(transport, "branch.conf");
store.load().unwrap();
assert!(store.store().get_sections().is_empty());
}
#[test]
fn transport_store_save_then_reload() {
let dir = tempfile::tempdir().unwrap();
let transport: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
let probe = Arc::new(LocalTransport::new(dir.path()));
let mut store = TransportIniFileStore::new(transport, "branch.conf");
store.load().unwrap();
let mut sec = store.store_mut().get_mutable_section(None);
sec.set("stacked_on_location", "../base");
store.store_mut().apply_changes(&sec);
store.save().unwrap();
let on_disk = probe.get_bytes("branch.conf").unwrap();
assert_eq!(on_disk, b"stacked_on_location = ../base\n");
}
#[test]
fn stack_returns_first_section_match() {
let registry = OptionRegistry::with_defaults();
let sections = vec![
Section::new(
Some("/path".to_string()),
vec![("nickname".to_string(), "specific".to_string())],
),
Section::new(None, vec![("nickname".to_string(), "generic".to_string())]),
];
let stack = Stack::new(sections, ®istry);
assert_eq!(stack.get("nickname").as_deref(), Some("specific"));
}
#[test]
fn stack_falls_back_to_default() {
let registry = OptionRegistry::with_defaults();
let stack = Stack::new(vec![], ®istry);
assert_eq!(stack.get("default_format").as_deref(), Some("2a"));
assert_eq!(stack.get("stacked_on_location"), None);
}
#[test]
fn config_error_display_wraps_source() {
let parse: ConfigError = ConfigObjError::MissingEquals("bad line".to_string()).into();
assert_eq!(
parse.to_string(),
"config parse error: line is not key = value: \"bad line\""
);
}
#[test]
fn named_section_reports_id_and_ordered_names() {
let mut store = IniFileStore::new();
store
.load_from_bytes(b"[/srv/trunk]\nnickname = trunk\nbound = True\n")
.unwrap();
let sections = store.get_sections();
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].id(), Some("/srv/trunk"));
let names: Vec<&str> = sections[0].iter_option_names().collect();
assert_eq!(names, vec!["nickname", "bound"]);
}
#[test]
fn mutable_section_tracks_id_and_dirtiness() {
let mut store = IniFileStore::new();
store.load_from_bytes(b"[loc]\nnickname = trunk\n").unwrap();
let mut sec = store.get_mutable_section(Some("loc"));
assert_eq!(sec.id(), Some("loc"));
assert!(!sec.is_dirty());
sec.set("bound", "True");
assert!(sec.is_dirty());
assert_eq!(sec.get("bound"), Some("True"));
}
#[test]
fn mutable_section_remove_only_clears_present_keys() {
let mut store = IniFileStore::new();
store.load_from_bytes(b"nickname = trunk\n").unwrap();
let mut sec = store.get_mutable_section(None);
sec.remove("absent");
assert!(!sec.is_dirty());
sec.remove("nickname");
assert!(sec.is_dirty());
assert_eq!(sec.get("nickname"), None);
}
#[test]
fn ini_file_store_tracks_loaded_state() {
let mut store = IniFileStore::new();
assert!(!store.is_loaded());
store.load_from_bytes(b"a = 1\n").unwrap();
assert!(store.is_loaded());
}
#[test]
fn transport_store_load_reads_existing_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("branch.conf"), b"nickname = trunk\n").unwrap();
let transport: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
let mut store = TransportIniFileStore::new(transport, "branch.conf");
store.load().unwrap();
let sections = store.store().get_sections();
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].get("nickname"), Some("trunk"));
}
}