use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::Deserialize;
use zond_engine::import::settings as engine_settings;
use zond_engine::{PortSet, ZondConfig};
pub(crate) const FILE_NAME: &str = "cli.toml";
pub(crate) const TEMPLATE: &str = include_str!("../assets/settings/cli.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum Presentation {
Pipe,
#[default]
Minimal,
Standard,
Fancy,
}
impl Presentation {
pub(crate) const ALL: [Presentation; 4] = [
Presentation::Pipe,
Presentation::Minimal,
Presentation::Standard,
Presentation::Fancy,
];
#[must_use]
pub(crate) fn as_str(self) -> &'static str {
match self {
Presentation::Pipe => "pipe",
Presentation::Minimal => "minimal",
Presentation::Standard => "standard",
Presentation::Fancy => "fancy",
}
}
#[must_use]
pub(crate) fn is_available(self) -> bool {
matches!(self, Presentation::Pipe | Presentation::Minimal)
}
}
impl fmt::Display for Presentation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, thiserror::Error)]
#[error("unknown presentation '{written}': expected one of {}", expected.join(", "))]
pub(crate) struct UnknownPresentation {
pub written: String,
pub expected: Vec<&'static str>,
}
impl FromStr for Presentation {
type Err = UnknownPresentation;
fn from_str(written: &str) -> Result<Self, Self::Err> {
Presentation::ALL
.into_iter()
.find(|mode| written.eq_ignore_ascii_case(mode.as_str()))
.ok_or_else(|| UnknownPresentation {
written: written.to_owned(),
expected: Presentation::ALL.map(Presentation::as_str).to_vec(),
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct Warning {
pub path: PathBuf,
pub key: String,
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: unknown setting '{}', ignored",
self.path.display(),
self.key
)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub(crate) enum SettingsError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path}: {source}")]
Malformed {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("{path}: {source}")]
BadValue {
path: PathBuf,
#[source]
source: UnknownPresentation,
},
#[error("{0}")]
Engine(#[from] engine_settings::SettingsError),
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Settings {
presentation: Option<Presentation>,
}
impl Settings {
#[must_use]
pub(crate) fn presentation(self) -> Option<Presentation> {
self.presentation
}
fn overlay(&mut self, other: Settings) {
if let Some(presentation) = other.presentation {
self.presentation = Some(presentation);
}
}
}
#[derive(Debug, Default, Deserialize)]
struct Document {
presentation: Option<String>,
#[serde(flatten)]
unknown: BTreeMap<String, toml::Value>,
}
fn parse(text: &str, path: &Path) -> Result<(Settings, Vec<Warning>), SettingsError> {
let document: Document = toml::from_str(text).map_err(|source| SettingsError::Malformed {
path: path.to_path_buf(),
source,
})?;
let presentation = document
.presentation
.as_deref()
.map(Presentation::from_str)
.transpose()
.map_err(|source| SettingsError::BadValue {
path: path.to_path_buf(),
source,
})?;
let warnings = document
.unknown
.into_keys()
.map(|key| Warning {
path: path.to_path_buf(),
key,
})
.collect();
Ok((Settings { presentation }, warnings))
}
#[must_use]
pub(crate) fn user_path() -> Option<PathBuf> {
engine_settings::paths::user_directory().map(|directory| directory.join(FILE_NAME))
}
#[must_use]
pub(crate) fn system_path() -> Option<PathBuf> {
engine_settings::paths::system()?
.parent()
.map(|directory| directory.join(FILE_NAME))
}
#[must_use]
pub(crate) fn layered() -> Vec<PathBuf> {
[system_path(), user_path()].into_iter().flatten().collect()
}
pub(crate) fn resolve() -> Result<(Settings, Vec<Warning>), SettingsError> {
let mut settings = Settings::default();
let mut warnings = Vec::new();
for path in layered() {
if !path.exists() {
continue;
}
let text = std::fs::read_to_string(&path).map_err(|source| SettingsError::Io {
path: path.clone(),
source,
})?;
let (parsed, found) = parse(&text, &path)?;
settings.overlay(parsed);
warnings.extend(found);
}
Ok((settings, warnings))
}
#[derive(Debug, Clone)]
pub(crate) struct EngineSettings {
pub(crate) config: ZondConfig,
pub(crate) ports: Option<PortSet>,
}
pub(crate) fn engine(
profile: Option<&str>,
) -> Result<(EngineSettings, Vec<String>), SettingsError> {
let (settings, warnings) = engine_settings::resolve(profile)?;
let mut config = ZondConfig::default();
settings.apply_to(&mut config);
let ports = settings
.ports()
.transpose()
.map_err(SettingsError::Engine)?;
let warnings = warnings
.into_iter()
.map(|warning| match warning.suggestion {
Some(suggestion) => format!(
"unknown engine setting '{}', ignored. Did you mean '{suggestion}'?",
warning.key
),
None => format!("unknown engine setting '{}', ignored", warning.key),
})
.collect();
Ok((EngineSettings { config, ports }, warnings))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Provisioned {
Created,
Existed,
}
#[must_use]
pub(crate) fn provision_all() -> (Vec<PathBuf>, Vec<String>) {
let mut created = Vec::new();
let mut problems = Vec::new();
let engine = engine_settings::paths::user().map(|path| (path, engine_settings::TEMPLATE));
let cli = user_path().map(|path| (path, TEMPLATE));
for (path, template) in [engine, cli].into_iter().flatten() {
match provision(&path, template) {
Ok(Provisioned::Created) => created.push(path),
Ok(Provisioned::Existed) => {}
Err(problem) => problems.push(problem.to_string()),
}
}
(created, problems)
}
pub(crate) fn provision(path: &Path, template: &str) -> Result<Provisioned, SettingsError> {
let fresh_directory = match path.parent() {
Some(parent) if !parent.exists() => {
create_directory(parent)?;
Some(parent)
}
_ => None,
};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(path) {
Ok(mut file) => {
use std::io::Write;
file.write_all(template.as_bytes())
.map_err(|source| SettingsError::Io {
path: path.to_path_buf(),
source,
})?;
if let Some(directory) = fresh_directory {
hand_to_invoker(directory);
}
hand_to_invoker(path);
Ok(Provisioned::Created)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Provisioned::Existed),
Err(source) => Err(SettingsError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn create_directory(path: &Path) -> Result<(), SettingsError> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(path).map_err(|source| SettingsError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(unix)]
fn hand_to_invoker(path: &Path) {
let invoker = std::env::var("SUDO_UID")
.ok()
.and_then(|uid| uid.parse::<u32>().ok())
.zip(
std::env::var("SUDO_GID")
.ok()
.and_then(|gid| gid.parse::<u32>().ok()),
);
if let Some((uid, gid)) = invoker {
let _ = std::os::unix::fs::chown(path, Some(uid), Some(gid));
}
}
#[cfg(not(unix))]
fn hand_to_invoker(_path: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
fn parse_text(text: &str) -> Result<(Settings, Vec<Warning>), SettingsError> {
parse(text, Path::new("cli.toml"))
}
#[test]
fn the_shipped_template_sets_nothing() {
let (settings, warnings) = parse_text(TEMPLATE).expect("the template is valid TOML");
assert_eq!(settings.presentation(), None);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn the_template_documents_every_mode_by_its_real_name() {
for mode in Presentation::ALL {
assert!(
TEMPLATE.contains(mode.as_str()),
"the template never mentions '{mode}'"
);
}
}
#[test]
fn a_presentation_is_read_from_the_document() {
let (settings, _) = parse_text(r#"presentation = "pipe""#).expect("a known mode");
assert_eq!(settings.presentation(), Some(Presentation::Pipe));
}
#[test]
fn an_unknown_key_is_a_warning_and_not_a_failure() {
let (settings, warnings) =
parse_text("colour = true\npresentation = \"minimal\"").expect("still usable");
assert_eq!(settings.presentation(), Some(Presentation::Minimal));
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].key, "colour");
}
#[test]
fn an_unusable_value_for_a_known_key_is_refused() {
let refused = parse_text(r#"presentation = "shiny""#);
let Err(SettingsError::BadValue { source, .. }) = refused else {
panic!("a value that is not a mode cannot be acted on");
};
assert_eq!(source.written, "shiny");
assert!(
source.expected.contains(&"minimal"),
"the error carries the names that would have worked: {:?}",
source.expected
);
}
#[test]
fn only_the_built_modes_report_themselves_available() {
assert!(Presentation::Pipe.is_available());
assert!(Presentation::Minimal.is_available());
assert!(!Presentation::Standard.is_available());
assert!(!Presentation::Fancy.is_available());
assert_eq!(Presentation::default(), Presentation::Minimal);
}
#[test]
fn a_mode_is_read_whatever_its_case() {
assert_eq!(
"MINIMAL".parse::<Presentation>().expect("known"),
Presentation::Minimal
);
assert_eq!(
"Fancy".parse::<Presentation>().expect("known"),
Presentation::Fancy
);
}
#[test]
fn every_mode_parses_back_from_its_own_name() {
for mode in Presentation::ALL {
assert_eq!(mode.as_str().parse::<Presentation>().expect("known"), mode);
}
}
#[test]
fn a_later_file_overrides_only_what_it_mentions() {
let mut settings = Settings {
presentation: Some(Presentation::Fancy),
};
settings.overlay(Settings::default());
assert_eq!(
settings.presentation(),
Some(Presentation::Fancy),
"a file that said nothing must not reset anything"
);
settings.overlay(Settings {
presentation: Some(Presentation::Minimal),
});
assert_eq!(settings.presentation(), Some(Presentation::Minimal));
}
#[test]
fn this_crates_file_sits_beside_the_engines() {
let (Some(ours), Some(theirs)) = (user_path(), engine_settings::paths::user()) else {
return;
};
assert_eq!(ours.parent(), theirs.parent());
assert_ne!(ours.file_name(), theirs.file_name());
}
}