use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
pub const PROJECT_CONFIGURATION_DIR_DEFAULT: &str = ".auxiliary/configuration/nbspec";
pub const CONFIGURATION_DIR_ENV: &str = "NBSPEC_CONFIG_DIR";
pub const SETTINGS_FILE: &str = "general.toml";
pub const ARCHIVE_DIR_DEFAULT: &str = "documentation/archives";
#[derive(Debug, Error)]
pub enum ConfigurationError {
#[error("configuration parse failure: {0}")]
Parse(#[from] toml::de::Error),
#[error("configuration invalid: {0}")]
Invalid(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub(crate) fn confinement_violation(path: &str) -> Option<&'static str> {
if path.is_empty() {
return Some("is empty");
}
if path.contains('\\') {
return Some("contains a backslash");
}
let parsed = Path::new(path);
if parsed.is_absolute() || path.starts_with('/') {
return Some("is absolute; paths must stay inside their root");
}
for component in parsed.components() {
match component {
std::path::Component::Normal(_) => {}
std::path::Component::ParentDir => {
return Some("contains a parent-directory component");
}
std::path::Component::CurDir => {
return Some("contains a current-directory component");
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Some("is absolute; paths must stay inside their root");
}
}
}
if path.split('/').any(|segment| segment.contains(':')) {
return Some("contains a drive or scheme prefix");
}
None
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct SettingsDocument {
#[serde(default)]
pub schema: Option<String>,
#[serde(default)]
pub project_configuration_directory: Option<PathBuf>,
#[serde(default)]
pub scratch_directory: Option<PathBuf>,
#[serde(default)]
pub archives: Option<bool>,
#[serde(default)]
pub archive_directory: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub struct Configuration {
pub schema: Option<String>,
pub project_directory: PathBuf,
pub scratch_directory: Option<PathBuf>,
pub archives: bool,
pub archive_directory: PathBuf,
}
pub fn load_configuration(project_root: &Path) -> Result<Configuration, ConfigurationError> {
let global = match global_settings_path() {
Some(path) => load_settings_document(&path)?,
None => SettingsDocument::default(),
};
let environment_directory = std::env::var_os(CONFIGURATION_DIR_ENV).map(PathBuf::from);
resolve_configuration(project_root, global, environment_directory)
}
pub fn resolve_configuration(
project_root: &Path,
global: SettingsDocument,
environment_directory: Option<PathBuf>,
) -> Result<Configuration, ConfigurationError> {
let directory = environment_directory
.or_else(|| global.project_configuration_directory.clone())
.unwrap_or_else(|| PathBuf::from(PROJECT_CONFIGURATION_DIR_DEFAULT));
let directory = if directory.is_absolute() {
directory
} else {
project_root.join(directory)
};
let project = load_settings_document(&directory.join(SETTINGS_FILE))?;
let scratch_directory = project
.scratch_directory
.or(global.scratch_directory)
.map(|scratch| {
if scratch.is_absolute() {
scratch
} else {
project_root.join(scratch)
}
});
let archive_directory = project
.archive_directory
.or(global.archive_directory)
.unwrap_or_else(|| PathBuf::from(ARCHIVE_DIR_DEFAULT));
if let Some(detail) = confinement_violation(&archive_directory.to_string_lossy()) {
return Err(ConfigurationError::Invalid(format!(
"archive_directory {archive_directory:?} {detail}"
)));
}
Ok(Configuration {
schema: project.schema.or(global.schema),
project_directory: directory,
scratch_directory,
archives: project.archives.or(global.archives).unwrap_or(true),
archive_directory,
})
}
fn global_settings_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "nbspec")
.map(|dirs| dirs.config_dir().join(SETTINGS_FILE))
}
fn load_settings_document(path: &Path) -> Result<SettingsDocument, ConfigurationError> {
if !path.is_file() {
return Ok(SettingsDocument::default());
}
Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
}