Skip to main content

ax_config_gen/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod config;
4mod output;
5mod ty;
6mod value;
7
8#[cfg(test)]
9mod tests;
10
11use std::path::{Path, PathBuf};
12
13use toml_edit::TomlError;
14
15pub use self::{
16    config::{Config, ConfigItem},
17    output::OutputFormat,
18    ty::ConfigType,
19    value::ConfigValue,
20};
21
22/// The error type on config parsing.
23pub enum ConfigErr {
24    /// TOML parsing error.
25    Parse(TomlError),
26    /// Invalid config value.
27    InvalidValue,
28    /// Invalid config type.
29    InvalidType,
30    /// Config value and type mismatch.
31    ValueTypeMismatch,
32    /// Other error.
33    Other(String),
34}
35
36impl From<TomlError> for ConfigErr {
37    fn from(e: TomlError) -> Self {
38        Self::Parse(e)
39    }
40}
41
42impl core::fmt::Display for ConfigErr {
43    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
44        match self {
45            Self::Parse(e) => write!(f, "{}", e),
46            Self::InvalidValue => write!(f, "Invalid config value"),
47            Self::InvalidType => write!(f, "Invalid config type"),
48            Self::ValueTypeMismatch => write!(f, "Config value and type mismatch"),
49            Self::Other(s) => write!(f, "{}", s),
50        }
51    }
52}
53
54impl core::fmt::Debug for ConfigErr {
55    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56        write!(f, "{}", self)
57    }
58}
59
60impl std::error::Error for ConfigErr {}
61
62/// A specialized [`Result`] type with [`ConfigErr`] as the error type.
63pub type ConfigResult<T> = Result<T, ConfigErr>;
64
65/// Options for loading, merging, updating, and writing config files.
66#[derive(Debug, Clone)]
67pub struct GenerateOptions {
68    /// Config specification files merged in order.
69    pub specs: Vec<PathBuf>,
70    /// Optional old config used to preserve existing values.
71    pub oldconfig: Option<PathBuf>,
72    /// Optional output file. If absent, generated text is returned only.
73    pub output: Option<PathBuf>,
74    /// Output format.
75    pub fmt: OutputFormat,
76    /// Values to override after specs and oldconfig are loaded.
77    pub writes: Vec<String>,
78    /// Whether to keep a `.old.*` backup when overwriting a changed output file.
79    pub keep_backup: bool,
80}
81
82impl GenerateOptions {
83    /// Create TOML generation options from config specification paths.
84    pub fn new(specs: impl IntoIterator<Item = PathBuf>) -> Self {
85        Self {
86            specs: specs.into_iter().collect(),
87            oldconfig: None,
88            output: None,
89            fmt: OutputFormat::Toml,
90            writes: Vec::new(),
91            keep_backup: false,
92        }
93    }
94}
95
96/// Result of a config generation run.
97#[derive(Debug, Clone)]
98pub struct GenerateReport {
99    /// Config items that were not present in the old config.
100    pub untouched: Vec<ConfigItem>,
101    /// Old config items that are not present in the specification.
102    pub extra: Vec<ConfigItem>,
103    /// Generated output text.
104    pub output: String,
105}
106
107/// Result of loading and updating config state before output generation.
108#[derive(Debug)]
109pub struct LoadReport {
110    /// Config after specs, old config, and write overrides have been applied.
111    pub config: Config,
112    /// Config items that were not present in the old config.
113    pub untouched: Vec<ConfigItem>,
114    /// Old config items that are not present in the specification.
115    pub extra: Vec<ConfigItem>,
116}
117
118/// Parse a config read argument in `key` or `table.key` form.
119pub fn parse_config_read_arg(arg: &str) -> ConfigResult<(String, String)> {
120    if let Some((table, key)) = arg.split_once('.') {
121        Ok((table.into(), key.into()))
122    } else {
123        Ok((Config::GLOBAL_TABLE_NAME.into(), arg.into()))
124    }
125}
126
127/// Parse a config write argument in `key=value` or `table.key=value` form.
128pub fn parse_config_write_arg(arg: &str) -> ConfigResult<(String, String, String)> {
129    let (item, value) = arg.split_once('=').ok_or_else(|| {
130        ConfigErr::Other(format!(
131            "Invalid config setting command `{}`, expected `table.key=value`",
132            arg
133        ))
134    })?;
135    if let Some((table, key)) = item.split_once('.') {
136        Ok((table.into(), key.into(), value.into()))
137    } else {
138        Ok((Config::GLOBAL_TABLE_NAME.into(), item.into(), value.into()))
139    }
140}
141
142/// Load and merge config specification files.
143pub fn load_config_specs(specs: &[PathBuf]) -> ConfigResult<Config> {
144    let mut config = Config::new();
145    for spec in specs {
146        let spec_toml = std::fs::read_to_string(spec).map_err(|err| {
147            ConfigErr::Other(format!(
148                "Failed to read config specification file {}: {}",
149                spec.display(),
150                err
151            ))
152        })?;
153        let sub_config = Config::from_toml(&spec_toml)?;
154        config.merge(&sub_config)?;
155    }
156    Ok(config)
157}
158
159/// Load one config file.
160pub fn load_config(path: impl AsRef<Path>) -> ConfigResult<Config> {
161    let path = path.as_ref();
162    let toml = std::fs::read_to_string(path).map_err(|err| {
163        ConfigErr::Other(format!(
164            "Failed to read config file {}: {}",
165            path.display(),
166            err
167        ))
168    })?;
169    Config::from_toml(&toml)
170}
171
172/// Apply write overrides to a loaded config.
173pub fn apply_config_writes(config: &mut Config, writes: &[String]) -> ConfigResult<()> {
174    for arg in writes {
175        let (table, key, value) = parse_config_write_arg(arg)?;
176        let new_value = ConfigValue::new(&value)?;
177        let item = config
178            .config_at_mut(&table, &key)
179            .ok_or_else(|| ConfigErr::Other(format!("Config item `{}` not found", arg)))?;
180        item.value_mut().update(new_value)?;
181    }
182    Ok(())
183}
184
185/// Load config state from specs, optional old config, and write overrides.
186pub fn load_config_state(options: &GenerateOptions) -> ConfigResult<LoadReport> {
187    let mut config = load_config_specs(&options.specs)?;
188    let (untouched, extra) = if let Some(oldconfig_path) = &options.oldconfig {
189        let oldconfig = load_config(oldconfig_path)?;
190        config.update(&oldconfig)?
191    } else {
192        (Vec::new(), Vec::new())
193    };
194
195    apply_config_writes(&mut config, &options.writes)?;
196    Ok(LoadReport {
197        config,
198        untouched,
199        extra,
200    })
201}
202
203/// Generate config output from specs, optional old config, and write overrides.
204pub fn generate_config(options: &GenerateOptions) -> ConfigResult<GenerateReport> {
205    let report = load_config_state(options)?;
206    let LoadReport {
207        config,
208        untouched,
209        extra,
210    } = report;
211    let output = config.dump(options.fmt.clone())?;
212    if let Some(path) = options.output.as_deref() {
213        write_config_output(path, &output, options.keep_backup)?;
214    }
215
216    Ok(GenerateReport {
217        untouched,
218        extra,
219        output,
220    })
221}
222
223/// Read one config item value from merged specs.
224pub fn read_config_value(specs: &[PathBuf], item: &str) -> ConfigResult<String> {
225    let config = load_config_specs(specs)?;
226    read_loaded_config_value(&config, item)
227}
228
229/// Read one string config item value from merged specs.
230pub fn read_config_string(specs: &[PathBuf], item: &str) -> ConfigResult<String> {
231    let config = load_config_specs(specs)?;
232    read_loaded_config_string(&config, item)
233}
234
235/// Read one config item value from an already loaded config.
236pub fn read_loaded_config_value(config: &Config, item: &str) -> ConfigResult<String> {
237    Ok(find_config_item(config, item)?.value().to_toml_value())
238}
239
240/// Read one string config item value from an already loaded config.
241pub fn read_loaded_config_string(config: &Config, item: &str) -> ConfigResult<String> {
242    find_config_item(config, item)?
243        .value()
244        .as_str()
245        .map(ToOwned::to_owned)
246        .ok_or_else(|| ConfigErr::Other(format!("Config item `{}` is not a string", item)))
247}
248
249fn find_config_item<'a>(config: &'a Config, item: &str) -> ConfigResult<&'a ConfigItem> {
250    let (table, key) = parse_config_read_arg(item)?;
251    config
252        .config_at(&table, &key)
253        .ok_or_else(|| ConfigErr::Other(format!("Config item `{}` not found", item)))
254}
255
256fn write_config_output(path: &Path, output: &str, keep_backup: bool) -> ConfigResult<()> {
257    if let Some(parent) = path.parent() {
258        std::fs::create_dir_all(parent).map_err(|err| {
259            ConfigErr::Other(format!(
260                "Failed to create output directory {}: {}",
261                parent.display(),
262                err
263            ))
264        })?;
265    }
266    if let Ok(oldconfig) = std::fs::read_to_string(path) {
267        if oldconfig == output {
268            return Ok(());
269        }
270        if keep_backup {
271            let bak_path = if let Some(ext) = path.extension() {
272                path.with_extension(format!("old.{}", ext.to_string_lossy()))
273            } else {
274                path.with_extension("old")
275            };
276            std::fs::write(&bak_path, oldconfig).map_err(|err| {
277                ConfigErr::Other(format!(
278                    "Failed to write backup config file {}: {}",
279                    bak_path.display(),
280                    err
281                ))
282            })?;
283        }
284    }
285    std::fs::write(path, output).map_err(|err| {
286        ConfigErr::Other(format!(
287            "Failed to write config file {}: {}",
288            path.display(),
289            err
290        ))
291    })
292}