use crate::collection::{Folder, HasId, Recipe, RecipeId};
use derive_more::From;
use indexmap::{IndexMap, map::Values};
use itertools::Itertools;
use serde::Serialize;
use slumber_util::yaml::SourceLocation;
use strum::EnumDiscriminants;
use thiserror::Error;
#[derive(derive_more::Debug, Default, Serialize)]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RecipeTree {
#[serde(flatten)]
tree: IndexMap<RecipeId, RecipeNode>,
#[debug(skip)] #[serde(skip)]
nodes_by_id: IndexMap<RecipeId, RecipeLookupKey>,
}
impl RecipeTree {
pub fn new(
tree: IndexMap<RecipeId, RecipeNode>,
) -> Result<Self, DuplicateRecipeIdError> {
let mut nodes_by_id = IndexMap::new();
let mut new = Self {
tree,
nodes_by_id: IndexMap::default(),
};
for (lookup_key, node) in new.iter() {
let evicted = nodes_by_id.insert(node.id().clone(), lookup_key);
if evicted.is_some() {
return Err(DuplicateRecipeIdError(node.id().clone()));
}
}
new.nodes_by_id = nodes_by_id;
Ok(new)
}
pub fn get_lookup_key(&self, id: &RecipeId) -> Option<&RecipeLookupKey> {
self.nodes_by_id.get(id)
}
pub fn get(&self, id: &RecipeId) -> Option<&RecipeNode> {
let lookup_key = self.nodes_by_id.get(id)?;
let mut nodes = &self.tree;
for (depth, step) in lookup_key.0.iter().enumerate() {
let is_last = depth == lookup_key.0.len() - 1;
let node = nodes.get(step).unwrap_or_else(|| {
panic!("Lookup key {lookup_key:?} does not point to a node")
});
if is_last {
return Some(node);
}
match node {
RecipeNode::Folder(folder) => nodes = &folder.children,
RecipeNode::Recipe(recipe) => panic!(
"Lookup key {lookup_key:?} attempts to traverse through \
recipe node `{}`",
recipe.id
),
}
}
None
}
pub fn try_get(
&self,
id: &RecipeId,
) -> Result<&RecipeNode, UnknownRecipeError> {
self.get(id).ok_or_else(|| UnknownRecipeError {
recipe_id: id.clone(),
all_recipes: self.recipe_ids().cloned().collect(),
})
}
pub fn get_folder(&self, id: &RecipeId) -> Option<&Folder> {
self.get(id).and_then(RecipeNode::folder)
}
pub fn get_recipe(&self, id: &RecipeId) -> Option<&Recipe> {
self.get(id).and_then(RecipeNode::recipe)
}
pub fn try_get_recipe(
&self,
id: &RecipeId,
) -> Result<&Recipe, UnknownRecipeError> {
self.get_recipe(id).ok_or_else(|| UnknownRecipeError {
recipe_id: id.clone(),
all_recipes: self.recipe_ids().cloned().collect(),
})
}
pub fn recipe_ids(&self) -> impl Iterator<Item = &RecipeId> {
self.nodes_by_id
.keys()
.filter(|id| self.get_recipe(id).is_some())
}
pub fn iter(&self) -> impl Iterator<Item = (RecipeLookupKey, &RecipeNode)> {
struct Iter<'a> {
stack: Vec<Values<'a, RecipeId, RecipeNode>>,
path: Vec<&'a RecipeId>,
}
impl<'a> Iterator for Iter<'a> {
type Item = (RecipeLookupKey, &'a RecipeNode);
fn next(&mut self) -> Option<Self::Item> {
while let Some(iter) = self.stack.last_mut() {
match iter.next() {
Some(node @ RecipeNode::Folder(folder)) => {
self.path.push(&folder.id);
self.stack.push(folder.children.values());
return Some(((&self.path).into(), node));
}
Some(node @ RecipeNode::Recipe(recipe)) => {
let mut lookup_key: RecipeLookupKey =
(&self.path).into();
lookup_key.0.push(recipe.id.clone());
return Some((lookup_key, node));
}
None => {
self.stack.pop();
self.path.pop();
}
}
}
None
}
}
Iter {
stack: vec![self.tree.values()],
path: Vec::new(),
}
}
}
#[cfg(any(test, feature = "test"))]
impl From<IndexMap<RecipeId, Recipe>> for RecipeTree {
fn from(value: IndexMap<RecipeId, Recipe>) -> Self {
value
.into_iter()
.map(|(id, recipe)| (id, RecipeNode::Recipe(recipe)))
.collect::<IndexMap<_, _>>()
.into()
}
}
#[cfg(any(test, feature = "test"))]
impl From<IndexMap<RecipeId, RecipeNode>> for RecipeTree {
fn from(tree: IndexMap<RecipeId, RecipeNode>) -> Self {
Self::new(tree).unwrap()
}
}
#[derive(Clone, Debug, From, Eq, Hash, PartialEq)]
pub struct RecipeLookupKey(Vec<RecipeId>);
impl RecipeLookupKey {
pub fn depth(&self) -> usize {
self.0.len() - 1
}
pub fn ancestors(&self) -> &[RecipeId] {
&self.0[0..self.0.len() - 1]
}
}
impl From<&Vec<&RecipeId>> for RecipeLookupKey {
fn from(value: &Vec<&RecipeId>) -> Self {
Self(value.iter().copied().cloned().collect())
}
}
impl IntoIterator for RecipeLookupKey {
type Item = RecipeId;
type IntoIter = <Vec<RecipeId> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[derive(Debug, From, Serialize, EnumDiscriminants)]
#[strum_discriminants(name(RecipeNodeType))]
#[cfg_attr(any(test, feature = "test"), derive(PartialEq))]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum RecipeNode {
Folder(Folder),
#[serde(rename = "request")]
Recipe(Recipe),
}
impl RecipeNode {
pub fn name(&self) -> &str {
match self {
Self::Folder(folder) => folder.name(),
Self::Recipe(recipe) => recipe.name(),
}
}
pub fn location(&self) -> &SourceLocation {
match self {
Self::Folder(folder) => &folder.location,
Self::Recipe(recipe) => &recipe.location,
}
}
pub fn recipe(&self) -> Option<&Recipe> {
match self {
Self::Recipe(recipe) => Some(recipe),
Self::Folder(_) => None,
}
}
pub fn folder(&self) -> Option<&Folder> {
match self {
Self::Recipe(_) => None,
Self::Folder(folder) => Some(folder),
}
}
}
#[derive(Debug, Error)]
#[error(
"Duplicate recipe/folder ID `{0}`; \
recipe/folder IDs must be globally unique"
)]
pub struct DuplicateRecipeIdError(RecipeId);
#[derive(Debug, Error)]
#[error(
"No recipe with ID `{recipe_id}`; available recipes: {}",
all_recipes.iter().join(", "),
)]
pub struct UnknownRecipeError {
pub recipe_id: RecipeId,
pub all_recipes: Vec<RecipeId>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{collection::cereal, test_util::by_id};
use indexmap::indexmap;
use itertools::Itertools;
use rstest::{fixture, rstest};
use serde_yaml::Value;
use slumber_util::{Factory, assert_err};
impl<const N: usize> From<[&str; N]> for RecipeLookupKey {
fn from(value: [&str; N]) -> Self {
value.into_iter().map(RecipeId::from).collect_vec().into()
}
}
fn id(s: &str) -> RecipeId {
s.into()
}
fn mapping<const N: usize>(items: [(&str, Value); N]) -> Value {
Value::Mapping(
items
.into_iter()
.map(|(key, value)| (Value::from(key), value))
.collect(),
)
}
fn folder<const N: usize>(children: [(&str, Value); N]) -> Value {
mapping([("requests", mapping(children))])
}
fn recipe() -> Value {
mapping([
("method", "GET".into()),
("url", "http://localhost/url".into()),
])
}
#[fixture]
fn tree() -> IndexMap<RecipeId, RecipeNode> {
by_id([
Recipe {
id: id("r1"),
..Recipe::factory(())
}
.into(),
Folder {
id: id("f1"),
children: by_id([
Folder {
id: id("f2"),
children: by_id([Recipe {
id: id("r2"),
..Recipe::factory(())
}
.into()]),
..Folder::factory(())
}
.into(),
Recipe {
id: id("r3"),
..Recipe::factory(())
}
.into(),
]),
..Folder::factory(())
}
.into(),
Recipe {
id: id("r4"),
..Recipe::factory(())
}
.into(),
])
}
#[rstest]
fn test_iter(tree: IndexMap<RecipeId, RecipeNode>) {
let tree = RecipeTree::new(tree).unwrap();
let expected: Vec<(RecipeLookupKey, RecipeId)> = vec![
(["r1"].into(), id("r1")),
(["f1"].into(), id("f1")),
(["f1", "f2"].into(), id("f2")),
(["f1", "f2", "r2"].into(), id("r2")),
(["f1", "r3"].into(), id("r3")),
(["r4"].into(), id("r4")),
];
assert_eq!(
tree.iter()
.map(|(key, node)| (key, node.id().clone()))
.collect_vec(),
expected
);
}
#[rstest]
#[case::recipe(
// Two requests share an ID
mapping([
("dupe", recipe()),
("f1", folder([("dupe", recipe())])),
])
)]
#[case::folder(
mapping([
("f1", folder([("dupe", folder([]))])),
("dupe", folder([])),
])
)]
#[case::request_folder(
mapping([
("f1", folder([("dupe", recipe())])),
("dupe", recipe()),
])
)]
fn test_duplicate_id(#[case] yaml_value: Value) {
assert_err!(
cereal::deserialize_recipe_tree(yaml_value),
"Duplicate recipe/folder ID `dupe`"
);
}
#[rstest]
fn test_deserialize(tree: IndexMap<RecipeId, RecipeNode>) {
let tree = RecipeTree {
tree,
nodes_by_id: indexmap! {
id("r1") => ["r1"].into(),
id("f1") => ["f1"].into(),
id("f2") => ["f1", "f2"].into(),
id("r2") => ["f1", "f2", "r2"].into(),
id("r3") => ["f1", "r3"].into(),
id("r4") => ["r4"].into(),
},
};
let yaml = mapping([
("r1", recipe()),
(
"f1",
folder([("f2", folder([("r2", recipe())])), ("r3", recipe())]),
),
("r4", recipe()),
]);
assert_eq!(
cereal::deserialize_recipe_tree(yaml).unwrap(),
tree,
"Deserialization failed"
);
}
}