mod discovery;
mod effective;
mod environment_layer;
mod error;
mod layer;
mod relative;
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::subprocess::DocumentDir;
use crate::{Environment, PluginKind, plugin_kinds, registry::omitted_feature};
pub use discovery::{
Document, PROJECT_DOCUMENT_NAME, SECRETS_RELATIVE_PATH, USER_DOCUMENT_RELATIVE_PATH, documents,
read_optional, readable_documents, 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(crate) use relative::rebased;
pub use relative::resolve_document_relative_paths;
pub const SECRETS_FILE_VARIABLE: &str = "ONETASKGRAPH_SECRETS_FILE";
pub const DEFAULT_PAGE_SIZE: NonZeroU32 = NonZeroU32::new(50).expect("50 is not zero");
pub const DEFAULT_INTERACTIVE: bool = true;
#[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,
document_dir: Option<DocumentDir>,
}
impl SourceConfig {
#[must_use]
pub fn plugin(&self) -> PluginKind {
self.plugin
}
#[must_use]
pub fn config(&self) -> &Value {
&self.config
}
#[must_use]
pub fn document_dir(&self) -> Option<&Path> {
self.document_dir.as_ref().map(DocumentDir::as_path)
}
}
#[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,
interactive: bool,
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,
interactive: bool,
sources: BTreeMap<String, SourceShape>,
}
impl Default for DocumentShape {
fn default() -> Self {
Self {
default_sources: None,
page_size: DEFAULT_PAGE_SIZE,
output: OutputFormat::default(),
interactive: DEFAULT_INTERACTIVE,
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(|| plugin_not_in_this_build(&key, &source.plugin))?;
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,
document_dir: None,
},
);
}
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,
interactive: shape.interactive,
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 interactive(&self) -> bool {
self.interactive
}
#[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,
}
#[must_use]
pub fn requested_output(
working_directory: Option<&Path>,
environment: &Environment,
flags: &Layer,
) -> OutputFormat {
let mut layers: Vec<Layer> = readable_documents(working_directory, environment)
.into_iter()
.filter_map(|document| {
let parsed: Value = serde_norway::from_str(&document.text).ok()?;
Layer::from_document(document.path, &parsed).ok()
})
.collect();
layers.extend(environment_layer::layer(environment).ok());
layers.push(flags.clone());
merge(&layers)
.values()
.find(|setting| setting.key.segments() == ["output"])
.and_then(|setting| serde_json::from_value(setting.value.clone()).ok())
.unwrap_or_default()
}
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 mut merged = merge(&layers);
resolve_document_relative_paths(&mut merged)?;
let mut config = Config::from_document(unflatten(&merged))?;
for (name, source) in &mut config.sources {
if source.plugin == PluginKind::Subprocess {
source.document_dir = relative::supplying_document_dir(&merged, name.as_str())?;
}
}
let secrets = Secrets::load(environment.clone())?;
Ok(Loaded {
effective: EffectiveConfig::new(&merged, &config, secrets.report()),
config,
secrets,
})
}
fn plugin_not_in_this_build(key: &str, plugin: &str) -> ConfigError {
let kinds = plugin_kinds().join(", ");
match omitted_feature(plugin) {
Some(feature) => ConfigError::setting(
format!("{key}.plugin"),
format!("the {plugin:?} plugin is not compiled into this binary"),
format!(
"use a build of onetaskgraph-core with its `{feature}` feature enabled, or \
one of: {kinds}."
),
),
None => ConfigError::setting(
format!("{key}.plugin"),
format!("no plugin named {plugin:?} is built into this binary"),
format!("use one of: {kinds}."),
),
}
}