skill_tree/
tree.rs

1use fehler::throws;
2use serde_derive::Deserialize;
3use std::{
4    collections::HashMap,
5    path::{Path, PathBuf},
6};
7
8#[derive(Debug, Deserialize)]
9pub struct SkillTree {
10    pub group: Vec<Group>,
11    pub cluster: Option<Vec<Cluster>>,
12    pub graphviz: Option<Graphviz>,
13    pub doc: Option<Doc>,
14}
15
16#[derive(Debug, Deserialize)]
17pub struct Graphviz {
18    pub rankdir: Option<String>,
19}
20
21#[derive(Default, Debug, Deserialize)]
22pub struct Doc {
23    pub columns: Vec<String>,
24    pub defaults: Option<HashMap<String, String>>,
25    pub emoji: Option<HashMap<String, EmojiMap>>,
26    pub include: Option<Vec<PathBuf>>,
27}
28
29pub type EmojiMap = HashMap<String, String>;
30
31#[derive(Debug, Deserialize)]
32pub struct Cluster {
33    pub name: String,
34    pub label: String,
35    pub color: Option<String>,
36    pub style: Option<String>,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct Group {
41    pub name: String,
42    pub cluster: Option<String>,
43    pub label: Option<String>,
44    pub requires: Option<Vec<String>>,
45    pub description: Option<Vec<String>>,
46    pub items: Vec<Item>,
47    pub width: Option<f64>,
48    pub status: Option<Status>,
49    pub href: Option<String>,
50    pub header_color: Option<String>,
51    pub description_color: Option<String>,
52}
53
54#[derive(Copy, Clone, Debug, PartialOrd, Ord, PartialEq, Eq, Hash)]
55pub struct GroupIndex(pub usize);
56
57pub type Item = HashMap<String, String>;
58
59#[derive(Copy, Clone, Debug, PartialOrd, Ord, PartialEq, Eq, Hash)]
60pub struct ItemIndex(pub usize);
61
62#[derive(Copy, Clone, Debug, Deserialize)]
63pub enum Status {
64    /// Can't work on it now
65    Blocked,
66
67    /// Would like to work on it, but need someone
68    Unassigned,
69
70    /// People are actively working on it
71    Assigned,
72
73    /// This is done!
74    Complete,
75}
76
77impl SkillTree {
78    pub fn load(path: &Path) -> anyhow::Result<SkillTree> {
79        let skill_tree_text = std::fs::read_to_string(path)?;
80        let mut tree = Self::parse(&skill_tree_text)?;
81        tree.import(path)?;
82        Ok(tree)
83    }
84
85    fn import(&mut self, root_path: &Path) -> anyhow::Result<()> {
86        if let Some(doc) = &mut self.doc {
87            if let Some(include) = &mut doc.include {
88                let include = include.clone();
89                for include_path in include {
90                    let tree_path = root_path.parent().unwrap().join(&include_path);
91                    let mut toml: SkillTree = SkillTree::load(&tree_path)?;
92
93                    // merge columns, and any defaults/emojis associated with the new columns
94                    let self_doc = self.doc.get_or_insert(Doc::default());
95                    let toml_doc = toml.doc.get_or_insert(Doc::default());
96                    for column in &toml_doc.columns {
97                        let columns = &mut self_doc.columns;
98                        if !columns.contains(column) {
99                            columns.push(column.clone());
100
101                            if let Some(value) =
102                                toml_doc.emoji.get_or_insert(HashMap::default()).get(column)
103                            {
104                                self_doc
105                                    .emoji
106                                    .get_or_insert(HashMap::default())
107                                    .insert(column.clone(), value.clone());
108                            }
109
110                            if let Some(value) = toml_doc
111                                .defaults
112                                .get_or_insert(HashMap::default())
113                                .get(column)
114                            {
115                                self_doc
116                                    .defaults
117                                    .get_or_insert(HashMap::default())
118                                    .insert(column.clone(), value.clone());
119                            }
120                        }
121                    }
122
123                    self.group.extend(toml.group.into_iter());
124
125                    self.cluster
126                        .get_or_insert(vec![])
127                        .extend(toml.cluster.into_iter().flatten());
128                }
129            }
130        }
131        Ok(())
132    }
133
134    #[throws(anyhow::Error)]
135    pub fn parse(text: &str) -> SkillTree {
136        toml::from_str(text)?
137    }
138
139    #[throws(anyhow::Error)]
140    pub fn validate(&self) {
141        // gather: valid requires entries
142
143        for group in &self.group {
144            group.validate(self)?;
145        }
146    }
147
148    pub fn groups(&self) -> impl Iterator<Item = &Group> {
149        self.group.iter()
150    }
151
152    pub fn group_named(&self, name: &str) -> Option<&Group> {
153        self.group.iter().find(|g| g.name == name)
154    }
155
156    /// Returns the expected column titles for each item (excluding the label).
157    pub fn columns(&self) -> &[String] {
158        if let Some(doc) = &self.doc {
159            &doc.columns
160        } else {
161            &[]
162        }
163    }
164
165    /// Translates an "input" into an emoji, returning "input" if not found.
166    pub fn emoji<'me>(&'me self, column: &str, input: &'me str) -> &'me str {
167        if let Some(doc) = &self.doc {
168            if let Some(emoji_maps) = &doc.emoji {
169                if let Some(emoji_map) = emoji_maps.get(column) {
170                    if let Some(output) = emoji_map.get(input) {
171                        return output;
172                    }
173                }
174            }
175        }
176        input
177    }
178}
179
180impl Group {
181    #[throws(anyhow::Error)]
182    pub fn validate(&self, tree: &SkillTree) {
183        // check: that `name` is a valid graphviz identifier
184
185        // check: each of the things in requires has the form
186        //        `identifier` or `identifier:port` and that all those
187        //        identifiers map to groups
188
189        for group_name in self.requires.iter().flatten() {
190            if tree.group_named(group_name).is_none() {
191                anyhow::bail!(
192                    "the group `{}` has a dependency on a group `{}` that does not exist",
193                    self.name,
194                    group_name,
195                )
196            }
197        }
198
199        for item in &self.items {
200            item.validate()?;
201        }
202    }
203
204    pub fn items(&self) -> impl Iterator<Item = &Item> {
205        self.items.iter()
206    }
207}
208
209pub trait ItemExt {
210    fn href(&self) -> Option<&String>;
211    fn label(&self) -> &String;
212    fn column_value<'me>(&'me self, tree: &'me SkillTree, c: &str) -> &'me str;
213
214    #[allow(redundant_semicolons)] // bug in "throws"
215    #[throws(anyhow::Error)]
216    fn validate(&self);
217}
218
219impl ItemExt for Item {
220    fn href(&self) -> Option<&String> {
221        self.get("href")
222    }
223
224    fn label(&self) -> &String {
225        self.get("label").unwrap()
226    }
227
228    fn column_value<'me>(&'me self, tree: &'me SkillTree, c: &str) -> &'me str {
229        if let Some(v) = self.get(c) {
230            return v;
231        }
232
233        if let Some(doc) = &tree.doc {
234            if let Some(defaults) = &doc.defaults {
235                if let Some(default_value) = defaults.get(c) {
236                    return default_value;
237                }
238            }
239        }
240
241        ""
242    }
243
244    #[throws(anyhow::Error)]
245    fn validate(&self) {
246        // check: each of the things in requires has the form
247        //        `identifier` or `identifier:port` and that all those
248        //        identifiers map to groups
249
250        // check: only contains known keys
251    }
252}