Skip to main content

basalt_api/recipes/
id.rs

1//! Stable identifier for crafting recipes.
2//!
3//! [`RecipeId`] mirrors Mojang's resource location convention
4//! (`namespace:path`). Vanilla recipes use the `minecraft` namespace;
5//! plugin recipes typically use the plugin's identifier.
6//!
7//! Two recipes with the same `result_id` are still distinguishable by
8//! their `RecipeId` — for example, two pickaxe recipes with different
9//! ingredient layouts have different ids.
10
11use std::fmt;
12
13/// Stable identifier for a crafting recipe.
14///
15/// The wire form is `"namespace:path"`. Both segments are
16/// non-empty UTF-8 strings; [`RecipeId::parse`] enforces this on
17/// input. Equality is by exact namespace + path.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct RecipeId {
20    /// Namespace segment (e.g. `"minecraft"`, `"my_plugin"`).
21    pub namespace: String,
22    /// Path segment (e.g. `"oak_planks"`, `"shaped_42"`).
23    pub path: String,
24}
25
26impl RecipeId {
27    /// Constructs a [`RecipeId`] from explicit namespace and path.
28    ///
29    /// Both arguments are taken via `Into<String>` so callers can
30    /// pass `&str`, owned `String`, or any other convertible type.
31    pub fn new(namespace: impl Into<String>, path: impl Into<String>) -> Self {
32        Self {
33            namespace: namespace.into(),
34            path: path.into(),
35        }
36    }
37
38    /// Constructs a vanilla [`RecipeId`] under the `"minecraft"` namespace.
39    ///
40    /// Shorthand for [`RecipeId::new("minecraft", path)`](Self::new).
41    pub fn vanilla(path: impl Into<String>) -> Self {
42        Self::new("minecraft", path)
43    }
44
45    /// Parses a `"namespace:path"` string into a [`RecipeId`].
46    ///
47    /// Returns `None` if the input does not contain exactly one colon
48    /// or if either segment is empty. The split is on the **first**
49    /// colon, so paths with embedded colons are not supported (and
50    /// no current Mojang recipe has one).
51    pub fn parse(input: &str) -> Option<Self> {
52        let (ns, path) = input.split_once(':')?;
53        if ns.is_empty() || path.is_empty() {
54            return None;
55        }
56        Some(Self::new(ns, path))
57    }
58}
59
60impl fmt::Display for RecipeId {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(f, "{}:{}", self.namespace, self.path)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn new_owns_strings() {
72        let id = RecipeId::new("plugin", "magic_sword");
73        assert_eq!(id.namespace, "plugin");
74        assert_eq!(id.path, "magic_sword");
75    }
76
77    #[test]
78    fn vanilla_uses_minecraft_namespace() {
79        let id = RecipeId::vanilla("oak_planks");
80        assert_eq!(id.namespace, "minecraft");
81        assert_eq!(id.path, "oak_planks");
82    }
83
84    #[test]
85    fn display_round_trips_with_parse() {
86        let id = RecipeId::vanilla("crafting_table");
87        let s = id.to_string();
88        assert_eq!(s, "minecraft:crafting_table");
89        assert_eq!(RecipeId::parse(&s), Some(id));
90    }
91
92    #[test]
93    fn parse_rejects_missing_colon() {
94        assert_eq!(RecipeId::parse("oak_planks"), None);
95    }
96
97    #[test]
98    fn parse_rejects_empty_namespace() {
99        assert_eq!(RecipeId::parse(":oak_planks"), None);
100    }
101
102    #[test]
103    fn parse_rejects_empty_path() {
104        assert_eq!(RecipeId::parse("minecraft:"), None);
105    }
106
107    #[test]
108    fn parse_takes_first_colon() {
109        // The first colon delimits the namespace; the rest is the
110        // path verbatim. No current vanilla recipe has a colon in
111        // its path, but the contract is documented.
112        let id = RecipeId::parse("ns:a:b").unwrap();
113        assert_eq!(id.namespace, "ns");
114        assert_eq!(id.path, "a:b");
115    }
116
117    #[test]
118    fn equality_is_exact() {
119        assert_eq!(
120            RecipeId::vanilla("oak_planks"),
121            RecipeId::new("minecraft", "oak_planks")
122        );
123        assert_ne!(
124            RecipeId::vanilla("oak_planks"),
125            RecipeId::vanilla("birch_planks")
126        );
127    }
128
129    #[test]
130    fn hashable_in_collections() {
131        use std::collections::HashSet;
132        let mut set = HashSet::new();
133        set.insert(RecipeId::vanilla("oak_planks"));
134        assert!(set.contains(&RecipeId::new("minecraft", "oak_planks")));
135    }
136}