use std::fmt;
use std::sync::Arc;
use jsonschema::error::ValidationErrorKind;
use onetaskgraph_plugin_api::{
SecretResolver, SharedClock, SourceError, SourceName, SourcePlugin, StatusMapping, TaskSource,
system_clock,
};
use serde_json::Value;
use crate::PluginKind;
use crate::config::{Config, ConfigError, SourceConfig};
use crate::plan::SourceFailure;
use crate::subprocess::SubprocessPlugin;
pub struct ResolvedSource {
name: SourceName,
source: Box<dyn TaskSource>,
}
impl ResolvedSource {
#[must_use]
pub fn adopt(name: SourceName, source: Box<dyn TaskSource>) -> Self {
Self { name, source }
}
#[must_use]
pub fn name(&self) -> &SourceName {
&self.name
}
#[must_use]
pub fn kind(&self) -> &str {
self.source.kind()
}
#[must_use]
pub fn source(&self) -> &dyn TaskSource {
self.source.as_ref()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnavailableSource {
name: SourceName,
kind: &'static str,
error: SourceError,
}
impl UnavailableSource {
#[must_use]
pub fn name(&self) -> &SourceName {
&self.name
}
#[must_use]
pub fn kind(&self) -> &str {
self.kind
}
#[must_use]
pub fn error(&self) -> &SourceError {
&self.error
}
#[must_use]
pub fn failure(&self) -> SourceFailure {
SourceFailure {
source: self.name.clone(),
error: self.error.clone(),
}
}
}
impl fmt::Debug for ResolvedSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResolvedSource")
.field("name", &self.name)
.field("kind", &self.kind())
.finish_non_exhaustive()
}
}
pub fn validate_sources(config: &Config) -> Result<(), ConfigError> {
for (name, source) in config.sources() {
checked_plugin(name, source)?;
}
Ok(())
}
pub fn resolve(
config: &Config,
secrets: &dyn SecretResolver,
) -> Result<Vec<ResolvedSource>, ConfigError> {
validate_sources(config)?;
let (built, unavailable) = resolve_available(config, secrets);
match unavailable.first() {
None => Ok(built),
Some(failed) => Err(ConfigError::setting(
format!("sources.{}", failed.name()),
failed.error().to_string(),
format!(
"correct that source's configuration, or remove it — `onetaskgraph \
config show` reports every setting under `sources.{}` and the layer it \
came from.",
failed.name()
),
)),
}
}
#[must_use]
pub fn resolve_available(
config: &Config,
secrets: &dyn SecretResolver,
) -> (Vec<ResolvedSource>, Vec<UnavailableSource>) {
resolve_available_with_clock(config, secrets, &system_clock())
}
#[must_use]
pub fn resolve_available_with_clock(
config: &Config,
secrets: &dyn SecretResolver,
clock: &SharedClock,
) -> (Vec<ResolvedSource>, Vec<UnavailableSource>) {
let mut built = Vec::new();
let mut unavailable = Vec::new();
for (name, source) in config.sources() {
let plugin = source.plugin().plugin();
let outcome = if source.plugin() == PluginKind::Subprocess {
SubprocessPlugin.build_from_document(
name,
source.config(),
secrets,
source.document_dir(),
)
} else {
plugin.build_with_clock(name, source.config(), secrets, Arc::clone(clock))
};
match outcome {
Ok(source) => built.push(ResolvedSource::adopt(name.clone(), source)),
Err(error) => unavailable.push(UnavailableSource {
name: name.clone(),
kind: plugin.kind(),
error,
}),
}
}
(built, unavailable)
}
fn checked_plugin(
name: &SourceName,
source: &SourceConfig,
) -> Result<Box<dyn SourcePlugin>, ConfigError> {
let plugin = source.plugin().plugin();
check_block(name, source.config(), plugin.as_ref())?;
Ok(plugin)
}
fn check_block(
name: &SourceName,
block: &Value,
plugin: &dyn SourcePlugin,
) -> Result<(), ConfigError> {
let schema = plugin.config_schema();
let validator = jsonschema::validator_for(schema.as_value()).map_err(|error| {
ConfigError::setting(
format!("sources.{name}.plugin"),
format!(
"the `{}` plugin declares a configuration schema this build cannot \
compile: {error}",
plugin.kind()
),
"that is a defect in this binary rather than in your configuration — please \
report it, naming the plugin above. Removing that source lets the rest of \
this configuration run in the meantime.",
)
})?;
let Some(problem) = validator.iter_errors(block).next() else {
return Ok(());
};
if schema.as_value().get("properties").is_none()
&& let Some(fields) = block.as_object()
&& let Some(first) = fields.keys().next()
{
return Err(ConfigError::setting(
format!("sources.{name}.config.{first}"),
format!(
"the `{}` plugin declares no configuration fields, so its `config:` block \
must be empty or absent; this one sets {}",
plugin.kind(),
fields.keys().cloned().collect::<Vec<_>>().join(", ")
),
format!(
"remove those fields — `onetaskgraph schema` prints what this plugin \
accepts under `plugin_config.{}`.",
plugin.kind()
),
));
}
let pointer = problem.instance_path().to_string();
if (pointer == "/status_mapping" || pointer.starts_with("/status_mapping/"))
&& let Some(mapping) = block.get("status_mapping")
&& let Err(error) = serde_json::from_value::<StatusMapping>(mapping.clone())
{
return Err(ConfigError::setting(
format!("sources.{name}.config.status_mapping"),
error.to_string(),
"write each category as a name, as null, or as an object naming a `task`, a \
`project` or both — `onetaskgraph schema` prints the grammar under \
`roots.StatusMapping`.",
));
}
let pointer = pointer.replace('/', ".");
let unexpected = match problem.kind() {
ValidationErrorKind::AdditionalProperties { unexpected } => unexpected.first(),
_ => None,
};
let key = match unexpected {
Some(field) => format!("sources.{name}.config{pointer}.{field}"),
None => format!("sources.{name}.config{pointer}"),
};
Err(ConfigError::setting(
key,
problem.to_string(),
format!(
"check that field against the `{}` plugin's schema — `onetaskgraph schema` \
prints it under `plugin_config.{}`.",
plugin.kind(),
plugin.kind()
),
))
}