#![doc = include_str!("README.md")]
mod entry;
#[cfg(test)]
mod tests;
mod yaml;
use self::entry::Entry;
use self::yaml::*;
use crate::{execution::BoxError, job::Relation};
use core::str::FromStr as _;
use glob::Paths;
use log::{debug, info};
use serde::Deserialize as _;
use serde_content::Value;
use std::{env, ffi::OsStr, fs, os::unix::ffi::OsStrExt, path::PathBuf};
use url::Url;
pub fn load(
what: impl Into<String>,
) -> impl IntoIterator<Item = Result<impl ConfigurationEntry, LoadError>> {
Loader::Init {
namespace: vec![],
what: what.into().into_boxed_str(),
}
}
pub fn read(
what: impl std::io::Read + 'static,
source: impl Into<String>,
) -> impl IntoIterator<Item = Result<impl ConfigurationEntry, LoadError>> {
let source = Url::from_str(&source.into()).expect("invalid url");
Loader::Reading {
namespace: vec![],
glob: vec![],
skip: vec![],
reader: serde_yaml::Deserializer::from_reader(what),
source,
}
}
pub trait ConfigurationEntry: core::fmt::Debug {
fn configure<C: Configuration>(self, config: &mut C) -> Result<(), C::Error>;
}
pub trait Configuration {
type Error: core::error::Error;
fn configure_project(
&mut self,
namespace: &[Box<str>],
name: Box<str>,
source: Url,
) -> Result<(), Self::Error>;
fn configure_interpretter(
&mut self,
namespace: &[Box<str>],
name: Box<str>,
source: Box<str>,
provider: Box<str>,
spec: Value<'static>,
) -> Result<(), Self::Error>;
fn configure_job(
&mut self,
namespace: &[Box<str>],
name: Box<str>,
source: Box<str>,
script: Vec<Box<str>>,
interpretter: Option<Box<str>>,
relations: Vec<Relation>,
) -> Result<(), Self::Error>;
}
#[derive(Default)]
enum Loader {
Init {
namespace: Vec<Box<str>>,
what: Box<str>,
},
Loading {
namespace: Vec<Box<str>>,
glob: Vec<(Url, GlobIncluder)>,
skip: Vec<Url>,
},
Reading {
namespace: Vec<Box<str>>,
glob: Vec<(Url, GlobIncluder)>,
skip: Vec<Url>,
reader: serde_yaml::Deserializer<'static>,
source: Url,
},
#[default]
Empty,
}
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum LoadError {
#[error("Unsupported configuration file type {0:?} for {1:?}")]
InvalidConfigurationFileType(Option<Box<str>>, PathBuf),
#[error("Invalid include reference {reference:?} at {source_url}")]
InvalidIncludeUrl {
reference: Box<str>,
source_url: Box<str>,
source: url::ParseError,
},
#[error("Invalid include reference {reference:?} at {source_url}")]
InvalidIncludeGlob {
reference: Box<str>,
source_url: Box<str>,
source: glob::PatternError,
},
#[error("Invalid config document: {source} at {source_url}")]
InvalidDocument {
source_url: Box<str>,
source: BoxError,
},
#[error("Could not find included config: {what:?} in {base:?}")]
IncludeNotFound { base: Box<str>, what: Box<str> },
#[error("Error including config: {what:?} in {base:?} - {source}")]
IncludeGlobError {
base: Box<str>,
what: Box<str>,
source: glob::GlobError,
},
#[error("Error including config: {path:?} - {source}")]
ErrorReadingConfig {
path: PathBuf,
source: std::io::Error,
},
}
impl Iterator for Loader {
type Item = Result<Entry, LoadError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let mut job = None;
*self = match core::mem::take(self) {
empty @ Loader::Empty => empty,
Loader::Init { namespace, what } => {
let cwd = env::current_dir().expect("CWD");
let cwd = cwd.to_str().expect("CWD");
let cwd = Url::parse(&format!("file://{cwd}/")).expect("CWD");
let mut glob = vec![];
let skip = vec![];
if let Err(e) = discover_config(&mut glob, &cwd, what) {
return Some(Err(e));
}
Loader::Loading {
namespace,
glob,
skip,
}
}
Loader::Loading {
namespace,
mut glob,
skip,
} => {
let (base, entry) = loop {
let (base, paths) = glob.last_mut()?;
let Some(entry) = paths.next() else {
glob.pop();
continue;
};
break (base, entry);
};
let path = match entry {
Ok(p) => p,
Err(e) => return Some(Err(e)),
};
let source = base
.join(path.to_str().expect("config URL"))
.expect("config URL");
match load_config(namespace, glob, skip, source) {
Err(e) => return Some(Err(e)),
Ok(it) => it,
}
}
Loader::Reading {
namespace,
mut glob,
skip,
mut reader,
source,
} => match reader.next() {
None => Loader::Loading {
namespace,
glob,
skip,
},
Some(document) => {
let doc = match Doc::deserialize(document) {
Ok(doc) => doc,
Err(e) => {
return Some(Err(LoadError::InvalidDocument {
source_url: source.as_str().into(),
source: e.into(),
}))
}
};
job = match doc {
Doc::Job(job) => Some(Entry::job(&namespace, &source, job)),
Doc::Interpretter(interpretter) => {
Some(Entry::interpretter(&namespace, &source, interpretter))
}
Doc::Empty => None,
Doc::Project { project } => {
Some(Entry::project(&namespace, &source, project))
}
Doc::Include(references) => {
for reference in references.into_iter().rev() {
if let Err(e) = discover_config(&mut glob, &source, reference) {
return Some(Err(e));
}
}
None
}
};
Loader::Reading {
namespace,
glob,
skip,
reader,
source,
}
}
},
};
if let Some(job) = job {
break Some(Ok(job));
}
if let Loader::Empty = self {
break None;
}
}
}
}
fn load_config(
namespace: Vec<Box<str>>,
mut glob: Vec<(Url, GlobIncluder)>,
mut skip: Vec<Url>,
source: Url,
) -> Result<Loader, LoadError> {
if skip.contains(&source) {
return Ok(Loader::Loading {
namespace,
glob,
skip,
});
}
skip.push(source.clone());
if source.scheme() != "file" {
todo!("other means of configuration - {}", source.scheme())
}
let mut path = PathBuf::from(source.path());
if path.is_dir() {
path.push("*.willdo.*");
let glob_includer = GlobIncluder::new(
path.to_str().expect("dir path glob"),
path.to_str().unwrap_or_default(),
&source,
);
glob.push((source, glob_includer?));
return Ok(Loader::Loading {
namespace,
glob,
skip,
});
}
let extension = path
.extension()
.and_then(OsStr::to_str)
.map(str::to_ascii_lowercase)
.map(Into::<Box<str>>::into);
let reader: serde_yaml::Deserializer<'_> = match extension {
Some(ext) => match ext.as_ref() {
"yml" | "yaml" => {
info!("loading {source}");
let reader = fs::File::open(&path)
.map_err(|source| LoadError::ErrorReadingConfig { path, source })?;
serde_yaml::Deserializer::from_reader(reader)
}
_ => return Err(LoadError::InvalidConfigurationFileType(Some(ext), path)),
},
None => return Err(LoadError::InvalidConfigurationFileType(None, path)),
};
Ok(Loader::Reading {
namespace,
glob,
skip,
reader,
source,
})
}
fn discover_config(
glob: &mut Vec<(Url, GlobIncluder)>,
base: &Url,
what: Box<str>,
) -> Result<(), LoadError> {
let url = Url::options()
.base_url(Some(base))
.parse(&what)
.map_err(|source| LoadError::InvalidIncludeUrl {
source_url: base.as_str().into(),
reference: what.clone(),
source,
})?;
if url.scheme() == "file" {
debug!("discovering {what:?} in {base:?}");
glob.push((base.clone(), GlobIncluder::new(url.path(), &what, base)?));
} else {
todo!("other means of configuration - {}", url.scheme())
}
Ok(())
}
struct GlobIncluder {
paths: Paths,
some: bool,
what: Box<str>,
base: Box<str>,
}
impl GlobIncluder {
pub fn new(pattern: &str, what: &str, base: &Url) -> Result<Self, LoadError> {
let opts = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
Ok(Self {
paths: glob::glob_with(pattern, opts).map_err(|source| {
LoadError::InvalidIncludeGlob {
source,
reference: what.into(),
source_url: base.as_str().into(),
}
})?,
some: false,
what: what.into(),
base: base.as_str().into(),
})
}
}
impl Iterator for GlobIncluder {
type Item = Result<PathBuf, LoadError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let next = self.paths.next().map(|r| match r {
Ok(p) => Ok(p),
Err(source) => Err(LoadError::IncludeGlobError {
source,
base: self.base.clone(),
what: self.what.clone(),
}),
});
break match next {
Some(Ok(ref path)) => {
match path
.as_os_str()
.as_bytes()
.windows(3)
.last()
.unwrap_or_default()
{
[_, b'/', b'.'] | [b'.', b'.'] | [b'/', b'.', b'.'] => {
debug!("skipping {:?}", path);
continue;
}
_ => {
info!("discovered {path:?}",);
self.some = true;
next
}
}
}
Some(Err(e)) => Some(Err(e)),
None if self.some => None,
None => Some(Err(LoadError::IncludeNotFound {
base: self.base.clone(),
what: self.what.clone(),
})),
};
}
}
}