mod discovery;
mod effective;
mod environment_layer;
mod error;
mod layer;
use std::collections::BTreeMap;
use std::num::NonZeroU32;
use std::path::Path;
use onetaskgraph_plugin_api::SourceName;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::secrets::Secrets;
use crate::{Environment, PluginKind, plugin_kinds};
pub use discovery::{
Document, PROJECT_DOCUMENT_NAME, SECRETS_RELATIVE_PATH, USER_DOCUMENT_RELATIVE_PATH, documents,
read_optional, secrets_path, user_document_path,
};
pub use effective::EffectiveConfig;
pub use environment_layer::{ENVIRONMENT_PREFIX, variable_for};
pub use error::ConfigError;
pub use layer::{Layer, Merged, Origin, Setting, SettingPath, merge, unflatten, value_from_text};
pub const SECRETS_FILE_VARIABLE: &str = "ONETASKGRAPH_SECRETS_FILE";
pub const DEFAULT_PAGE_SIZE: NonZeroU32 = NonZeroU32::new(50).expect("50 is not zero");
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SourceConfig {
plugin: PluginKind,
config: Value,
}
impl SourceConfig {
#[must_use]
pub fn plugin(&self) -> PluginKind {
self.plugin
}
#[must_use]
pub fn config(&self) -> &Value {
&self.config
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceShape {
plugin: String,
#[serde(default = "empty_block")]
config: Value,
}
fn empty_block() -> Value {
Value::Object(Map::new())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
default_sources: Option<Vec<SourceName>>,
page_size: NonZeroU32,
output: OutputFormat,
sources: BTreeMap<SourceName, SourceConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct DocumentShape {
#[serde(deserialize_with = "one_or_many")]
default_sources: Option<Vec<String>>,
page_size: NonZeroU32,
output: OutputFormat,
sources: BTreeMap<String, SourceShape>,
}
impl Default for DocumentShape {
fn default() -> Self {
Self {
default_sources: None,
page_size: DEFAULT_PAGE_SIZE,
output: OutputFormat::default(),
sources: BTreeMap::new(),
}
}
}
fn one_or_many<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Vec<String>>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match Option::<OneOrMany>::deserialize(deserializer)? {
None => None,
Some(OneOrMany::One(name)) => Some(vec![name]),
Some(OneOrMany::Many(names)) => Some(names),
})
}
impl Config {
pub fn from_document(document: Value) -> Result<Self, ConfigError> {
let shape: DocumentShape = serde_path_to_error::deserialize(document).map_err(|error| {
let key = error.path().to_string();
let key = if key.is_empty() || key == "." {
"the document's root".to_owned()
} else {
key
};
ConfigError::setting(
key,
error.into_inner().to_string(),
"correct that setting, or remove it — `onetaskgraph config show` lists \
every setting this build reads and the layer each came from.",
)
})?;
let mut sources = BTreeMap::new();
for (name, source) in shape.sources {
let key = format!("sources.{name}");
let plugin = PluginKind::parse(&source.plugin).ok_or_else(|| {
ConfigError::setting(
format!("{key}.plugin"),
format!(
"no plugin named {:?} is built into this binary",
source.plugin
),
format!("use one of: {}.", plugin_kinds().join(", ")),
)
})?;
let name = SourceName::new(name).map_err(|error| {
ConfigError::setting(
&key,
error.to_string(),
"rename the source to lower-case letters, digits and hyphens — an \
underscore would make the ONETASKGRAPH_SOURCES__<NAME>__ mapping \
ambiguous.",
)
})?;
sources.insert(
name,
SourceConfig {
plugin,
config: source.config,
},
);
}
let default_sources = shape
.default_sources
.map(|names| resolve_default_sources(&names, &sources))
.transpose()?;
let config = Self {
default_sources,
page_size: shape.page_size,
output: shape.output,
sources,
};
crate::resolve::validate_sources(&config)?;
Ok(config)
}
#[must_use]
pub fn page_size(&self) -> NonZeroU32 {
self.page_size
}
#[must_use]
pub fn output(&self) -> OutputFormat {
self.output
}
#[must_use]
pub fn sources(&self) -> &BTreeMap<SourceName, SourceConfig> {
&self.sources
}
#[must_use]
pub fn default_sources(&self) -> Option<&[SourceName]> {
self.default_sources.as_deref()
}
#[must_use]
pub fn selected_sources(&self) -> Vec<SourceName> {
self.default_sources
.clone()
.unwrap_or_else(|| self.sources.keys().cloned().collect())
}
}
fn resolve_default_sources(
names: &[String],
sources: &BTreeMap<SourceName, SourceConfig>,
) -> Result<Vec<SourceName>, ConfigError> {
names
.iter()
.map(|name| {
let selected = SourceName::new(name.clone()).map_err(|error| {
ConfigError::setting(
"default_sources",
error.to_string(),
"name a configured source; `onetaskgraph config show` lists them.",
)
})?;
if sources.contains_key(&selected) {
Ok(selected)
} else {
Err(ConfigError::setting(
"default_sources",
format!("no source named {name:?} is configured"),
format!(
"name one of the configured sources ({}), or configure {name:?} under \
`sources`.",
source_list(sources)
),
))
}
})
.collect()
}
fn source_list(sources: &BTreeMap<SourceName, SourceConfig>) -> String {
if sources.is_empty() {
"none are".to_owned()
} else {
sources
.keys()
.map(SourceName::as_str)
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone)]
pub struct Loaded {
pub config: Config,
pub secrets: Secrets,
pub effective: EffectiveConfig,
}
pub fn load(
working_directory: &Path,
environment: &Environment,
flags: &Layer,
) -> Result<Loaded, ConfigError> {
let mut layers = Vec::new();
for document in documents(working_directory, environment)? {
let parsed: Value =
serde_norway::from_str(&document.text).map_err(|error| ConfigError::Syntax {
path: document.path.clone(),
message: error.to_string(),
})?;
layers.push(Layer::from_document(document.path, &parsed)?);
}
layers.push(environment_layer::layer(environment)?);
layers.push(flags.clone());
let merged = merge(&layers);
let config = Config::from_document(unflatten(&merged))?;
let secrets = Secrets::load(environment.clone())?;
Ok(Loaded {
effective: EffectiveConfig::new(&merged, &config, secrets.report()),
config,
secrets,
})
}