use super::{config_dir, glob_match, home_dir};
use anyhow::{anyhow, Result};
use beau_collector::BeauCollector as _;
use config::{Config, File};
use serde::{
de::{MapAccess, Visitor},
Deserialize, Deserializer,
};
use std::{
collections::{HashMap, HashSet, VecDeque},
ffi::OsString,
fmt,
marker::PhantomData,
path::PathBuf,
str::FromStr,
};
use tracing::{debug, instrument, trace, warn};
#[derive(Debug, PartialEq, Eq)]
pub struct Cluster {
pub root: RootEntry,
pub nodes: HashMap<String, NodeEntry>,
}
impl Cluster {
#[instrument(level = "debug")]
pub fn new() -> Result<Self> {
trace!("Load cluster configuration");
let path = config_dir()?.join("root.toml");
debug!("Load root at {path:?}");
let root: RootEntry =
Config::builder().add_source(File::from(path)).build()?.try_deserialize()?;
let pattern = config_dir()?.join("nodes").join("*.toml").to_string_lossy().into_owned();
let mut nodes = HashMap::new();
for entry in glob::glob(pattern.as_str())? {
let path = entry?;
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
debug!("Load node {name:?} at {path:?}");
let node: NodeEntry = Config::builder()
.add_source(File::from(path).required(false))
.build()?
.try_deserialize()?;
nodes.insert(name, node);
}
let mut cluster = Self { root, nodes };
cluster.dependency_existence_check()?;
cluster.acyclic_check()?;
cluster.expand_work_dir_aliases()?;
Ok(cluster)
}
pub fn dependency_iter(&self, node: impl Into<String>) -> DependencyIter<'_> {
let mut stack = VecDeque::new();
stack.push_front(node.into());
DependencyIter { graph: &self.nodes, visited: HashSet::new(), stack }
}
pub fn match_targets(&self, mut targets: Vec<String>) -> Result<Vec<String>> {
let mut results = Vec::new();
targets.dedup();
for target in &mut targets {
target.retain(|c| !c.is_whitespace());
}
if let Some(index) = targets.iter().position(|x| *x == "root") {
targets.swap_remove(index);
results.push("root".into());
}
results.append(&mut glob_match(targets, self.nodes.keys()));
Ok(results)
}
#[instrument(skip(self), level = "debug")]
fn dependency_existence_check(&self) -> Result<()> {
trace!("Perform dependency existence check on cluster");
let mut results = Vec::new();
for node in self.nodes.values() {
for dependency in node.settings.dependencies.iter().flatten() {
if !self.nodes.contains_key(dependency) {
results.push(Err(anyhow!(
"Node dependency {dependency:?} is not defined in cluster"
)));
} else {
results.push(Ok(()));
}
}
}
results.into_iter().bcollect::<_>()
}
#[instrument(skip(self), level = "debug")]
fn acyclic_check(&self) -> Result<()> {
trace!("Perform acyclic check on cluster");
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
let mut visited: HashSet<String> = HashSet::new();
for (name, node) in &self.nodes {
in_degree.entry(name.clone()).or_insert(0);
for dependency in node.settings.dependencies.iter().flatten() {
*in_degree.entry(dependency.clone()).or_insert(0) += 1;
}
}
for (name, degree) in &in_degree {
if *degree == 0 {
queue.push_back(name.clone());
}
}
while let Some(current) = queue.pop_front() {
for dependency in self.nodes[¤t].settings.dependencies.iter().flatten() {
*in_degree.get_mut(dependency).unwrap() -= 1;
if *in_degree.get(dependency).unwrap() == 0 {
queue.push_back(dependency.clone());
}
}
visited.insert(current);
}
if visited.len() != self.nodes.len() {
let cycle: Vec<String> =
self.nodes.keys().filter(|key| !visited.contains(*key)).cloned().collect();
return Err(anyhow!("Cluster contains cycle(s): {cycle:?}"));
}
debug!("Topological sort of cluster nodes: {visited:?}");
Ok(())
}
#[instrument(skip(self), level = "debug")]
fn expand_work_dir_aliases(&mut self) -> Result<()> {
trace!("Expand working directory aliases of nodes");
for node in self.nodes.values_mut() {
let expand = shellexpand::full(
node.settings.deployment.work_dir_alias.0.to_string_lossy().as_ref(),
)?
.into_owned();
node.settings.deployment.work_dir_alias = WorkDirAlias::new(expand);
}
Ok(())
}
}
#[derive(Debug)]
pub struct DependencyIter<'cluster> {
graph: &'cluster HashMap<String, NodeEntry>,
visited: HashSet<String>,
stack: VecDeque<String>,
}
impl<'cluster> Iterator for DependencyIter<'cluster> {
type Item = (&'cluster str, &'cluster NodeEntry);
fn next(&mut self) -> Option<Self::Item> {
if let Some(node) = self.stack.pop_front() {
let (name, node) = self.graph.get_key_value(&node)?;
for dependency in node.settings.dependencies.iter().flatten() {
if !self.visited.contains(dependency) {
self.stack.push_front(dependency.clone());
self.visited.insert(dependency.clone());
}
}
return Some((name.as_str(), node));
}
None
}
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct RootEntry {
pub settings: RootEntrySettings,
}
impl RootEntry {
pub fn builder() -> Result<RootEntryBuilder> {
RootEntryBuilder::new()
}
pub fn try_default() -> Result<Self> {
Ok(RootEntry {
settings: RootEntrySettings {
work_dir_alias: WorkDirAlias::new(config_dir()?),
excluded: None,
},
})
}
}
#[derive(Debug)]
pub struct RootEntryBuilder {
settings: RootEntrySettings,
}
impl RootEntryBuilder {
pub fn new() -> Result<Self> {
Ok(Self {
settings: RootEntrySettings {
work_dir_alias: WorkDirAlias::new(config_dir()?),
excluded: None,
},
})
}
pub fn deploy_to_config_dir(mut self) -> Result<Self> {
self.settings.work_dir_alias = WorkDirAlias::new(config_dir()?);
Ok(self)
}
pub fn deploy_to_home_dir(mut self) -> Result<Self> {
self.settings.work_dir_alias = WorkDirAlias::new(home_dir()?);
Ok(self)
}
pub fn excluded(mut self, rules: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.excluded = Some(rules.into_iter().map(Into::into).collect());
self
}
pub fn build(self) -> RootEntry {
RootEntry { settings: self.settings }
}
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct RootEntrySettings {
#[serde(deserialize_with = "deserialize_root_work_dir_alias")]
pub work_dir_alias: WorkDirAlias,
pub excluded: Option<Vec<String>>,
}
fn deserialize_root_work_dir_alias<'de, D>(deserializer: D) -> Result<WorkDirAlias, D::Error>
where
D: Deserializer<'de>,
{
let result: String = Deserialize::deserialize(deserializer)?;
match result.as_str() {
"config_dir" => Ok(WorkDirAlias::new(config_dir().map_err(serde::de::Error::custom)?)),
"home_dir" => Ok(WorkDirAlias::new(home_dir().map_err(serde::de::Error::custom)?)),
_ => Err(anyhow!("Invalid deployment option for root")).map_err(serde::de::Error::custom),
}
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct NodeEntry {
pub settings: NodeEntrySettings,
}
impl NodeEntry {
pub fn builder() -> Result<NodeEntryBuilder> {
NodeEntryBuilder::new()
}
}
#[derive(Debug)]
pub struct NodeEntryBuilder {
settings: NodeEntrySettings,
}
impl NodeEntryBuilder {
pub fn new() -> Result<Self> {
Ok(Self {
settings: NodeEntrySettings {
deployment: NodeEntryDeployment {
kind: DeploymentKind::Normal,
work_dir_alias: WorkDirAlias::try_default()?,
},
url: String::default(),
excluded: None,
dependencies: None,
},
})
}
pub fn deployment(mut self, kind: DeploymentKind, work_dir_alias: WorkDirAlias) -> Self {
self.settings.deployment = NodeEntryDeployment { kind, work_dir_alias };
self
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.settings.url = url.into();
self
}
pub fn excluded(mut self, rules: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.excluded = Some(rules.into_iter().map(Into::into).collect());
self
}
pub fn dependencies(mut self, nodes: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.dependencies = Some(nodes.into_iter().map(Into::into).collect());
self
}
pub fn build(self) -> NodeEntry {
NodeEntry { settings: self.settings }
}
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct NodeEntrySettings {
#[serde(deserialize_with = "deserialize_node_deployment")]
pub deployment: NodeEntryDeployment,
pub url: String,
pub excluded: Option<Vec<String>>,
pub dependencies: Option<Vec<String>>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct NodeEntryDeployment {
pub kind: DeploymentKind,
pub work_dir_alias: WorkDirAlias,
}
impl FromStr for NodeEntryDeployment {
type Err = anyhow::Error;
fn from_str(data: &str) -> Result<Self, Self::Err> {
let (kind, work_dir_alias) = match data {
"normal" => (DeploymentKind::Normal, WorkDirAlias::try_default()?),
"bare_alias" => (DeploymentKind::BareAlias, WorkDirAlias::new(home_dir()?)),
_ => return Err(anyhow!("Invalid deployment kind")),
};
Ok(NodeEntryDeployment { kind, work_dir_alias })
}
}
struct NodeEntryDeploymentVisitor(PhantomData<fn() -> NodeEntryDeployment>);
impl<'de> Visitor<'de> for NodeEntryDeploymentVisitor {
type Value = NodeEntryDeployment;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string or map")
}
fn visit_str<E>(self, value: &str) -> Result<NodeEntryDeployment, E>
where
E: serde::de::Error,
{
FromStr::from_str(value).map_err(serde::de::Error::custom)
}
fn visit_map<M>(self, map: M) -> Result<NodeEntryDeployment, M::Error>
where
M: MapAccess<'de>,
{
Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(map))
}
}
fn deserialize_node_deployment<'de, D>(deserializer: D) -> Result<NodeEntryDeployment, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(NodeEntryDeploymentVisitor(PhantomData))
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum DeploymentKind {
Normal,
BareAlias,
}
impl DeploymentKind {
pub fn is_bare_alias(&self) -> bool {
match self {
DeploymentKind::Normal => false,
DeploymentKind::BareAlias => true,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
pub struct WorkDirAlias(pub(crate) PathBuf);
impl WorkDirAlias {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
pub fn try_default() -> Result<Self> {
Ok(Self(home_dir()?))
}
pub fn to_os_string(&self) -> OsString {
OsString::from(self.0.to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context;
use pretty_assertions::assert_eq as pretty_assert_eq;
use sealed_test::prelude::*;
use simple_test_case::test_case;
#[test_case(
r#"
[settings]
work_dir_alias = "home_dir"
"#,
RootEntry {
settings: RootEntrySettings {
work_dir_alias: WorkDirAlias::new("some/path"),
excluded: None,
}
};
"home_dir"
)]
#[test_case(
r#"
[settings]
work_dir_alias = "config_dir"
"#,
RootEntry {
settings: RootEntrySettings {
work_dir_alias: WorkDirAlias::new("some/path/.config/ocd"),
excluded: None,
}
};
"config_dir"
)]
#[sealed_test(env = [("HOME", "some/path"), ("XDG_CONFIG_HOME", "some/path/.config")])]
fn root_entry_valid_work_dir_alias(config: &str, expect: RootEntry) -> Result<()> {
let result: RootEntry = toml::de::from_str(config)?;
pretty_assert_eq!(result, expect);
Ok(())
}
#[test]
fn root_entry_invalid_work_dir_alias() {
let config = r#"
[settings]
work_dir_alias = "data_dir"
"#;
let result: Result<RootEntry> = toml::de::from_str(config).with_context(|| "should fail!");
assert!(result.is_err());
}
#[test_case(
r#"
[settings]
deployment = "normal"
url = "https://some/url"
"#,
NodeEntry {
settings: NodeEntrySettings {
deployment: NodeEntryDeployment {
kind: DeploymentKind::Normal,
work_dir_alias: WorkDirAlias::try_default()?,
},
url: "https://some/url".into(),
excluded: None,
dependencies: None,
}
};
"str_normal"
)]
#[test_case(
r#"
[settings]
deployment = "bare_alias"
url = "https://some/url"
"#,
NodeEntry {
settings: NodeEntrySettings {
deployment: NodeEntryDeployment {
kind: DeploymentKind::BareAlias,
work_dir_alias: WorkDirAlias::new("some/path"),
},
url: "https://some/url".into(),
excluded: None,
dependencies: None,
}
};
"str_bare_alias"
)]
#[test_case(
r#"
[settings]
deployment = { kind = "normal", work_dir_alias = "blah/blah" }
url = "https://some/url"
"#,
NodeEntry {
settings: NodeEntrySettings {
deployment: NodeEntryDeployment {
kind: DeploymentKind::Normal,
work_dir_alias: WorkDirAlias::new("blah/blah"),
},
url: "https://some/url".into(),
excluded: None,
dependencies: None,
}
};
"map_normal"
)]
#[test_case(
r#"
[settings]
deployment = { kind = "bare_alias", work_dir_alias = "blah/blah" }
url = "https://some/url"
"#,
NodeEntry {
settings: NodeEntrySettings {
deployment: NodeEntryDeployment {
kind: DeploymentKind::BareAlias,
work_dir_alias: WorkDirAlias::new("blah/blah"),
},
url: "https://some/url".into(),
excluded: None,
dependencies: None,
}
};
"map_bare_alias"
)]
#[sealed_test(env = [("HOME", "some/path"), ("XDG_CONFIG_HOME", "some/path/.config")])]
fn node_entry_valid_deployment(config: &str, expect: NodeEntry) -> Result<()> {
let node: NodeEntry = toml::de::from_str(config)?;
pretty_assert_eq!(node, expect);
Ok(())
}
#[test_case(
r#"
[settings]
deployment = "snafu"
url = "https://some/url"
"#;
"invalid_str"
)]
#[test_case(
r#"
[settings]
deployment = { kind = "snafu", work_dir_alias = "blah/blah" }
url = "https://some/url"
"#;
"unknown_field"
)]
fn node_entry_invalid_deployment(config: &str) {
let result: Result<NodeEntry> = toml::de::from_str(config).with_context(|| "should fail!");
assert!(result.is_err());
}
}