1use crate::manifest::{StoryDefinition, StoryManifest};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct StoryEntry {
5 pub definition: StoryDefinition,
6 pub renderer_symbol: &'static str,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct StoryNavigationNode {
11 pub segment: String,
12 pub title_path: String,
13 pub story_id: Option<String>,
14 pub children: Vec<Self>,
15}
16
17pub trait StoryTreeEntry {
18 fn story_id(&self) -> &str;
19 fn story_title(&self) -> &str;
20}
21
22#[derive(Debug, Default)]
23pub struct ShowcaseRegistry {
24 stories: Vec<StoryEntry>,
25}
26
27impl ShowcaseRegistry {
28 pub fn register(&mut self, entry: StoryEntry) {
29 self.stories.push(entry);
30 }
31
32 pub fn manifest(&self) -> StoryManifest {
33 let mut manifest = StoryManifest::new(1);
34 for story in &self.stories {
35 manifest.add_story(story.definition.clone());
36 }
37 manifest
38 }
39
40 pub fn story_count(&self) -> usize {
41 self.stories.len()
42 }
43}
44
45pub fn build_story_navigation<T: StoryTreeEntry>(stories: &[T]) -> Vec<StoryNavigationNode> {
46 let mut nodes = Vec::new();
47
48 for story in stories {
49 let title = story.story_title().trim();
50 let segments = split_story_title(title);
51 if segments.is_empty() {
52 continue;
53 }
54
55 insert_story_node(&mut nodes, &segments, story.story_id());
56 }
57
58 nodes
59}
60
61fn split_story_title(title: &str) -> Vec<&str> {
62 title.split('/').map(str::trim).filter(|segment| !segment.is_empty()).collect()
63}
64
65fn insert_story_node(nodes: &mut Vec<StoryNavigationNode>, segments: &[&str], story_id: &str) {
66 let segment = segments[0];
67 let node_index = match nodes.iter().position(|node| node.segment == segment) {
68 Some(index) => index,
69 None => {
70 let title_path = segment.to_owned();
71 nodes.push(StoryNavigationNode {
72 segment: segment.to_owned(),
73 title_path,
74 story_id: None,
75 children: Vec::new(),
76 });
77 nodes.len() - 1
78 }
79 };
80
81 let node = &mut nodes[node_index];
82 if segments.len() == 1 {
83 node.story_id = Some(story_id.to_owned());
84 return;
85 }
86
87 insert_story_child(&mut node.children, &node.title_path, &segments[1..], story_id);
88}
89
90fn insert_story_child(
91 children: &mut Vec<StoryNavigationNode>,
92 parent_path: &str,
93 segments: &[&str],
94 story_id: &str,
95) {
96 let segment = segments[0];
97 let node_index = match children.iter().position(|node| node.segment == segment) {
98 Some(index) => index,
99 None => {
100 let title_path = format!("{parent_path}/{segment}");
101 children.push(StoryNavigationNode {
102 segment: segment.to_owned(),
103 title_path,
104 story_id: None,
105 children: Vec::new(),
106 });
107 children.len() - 1
108 }
109 };
110
111 let node = &mut children[node_index];
112 if segments.len() == 1 {
113 node.story_id = Some(story_id.to_owned());
114 return;
115 }
116
117 insert_story_child(&mut node.children, &node.title_path, &segments[1..], story_id);
118}
119
120impl StoryTreeEntry for StoryEntry {
121 fn story_id(&self) -> &str {
122 &self.definition.id
123 }
124
125 fn story_title(&self) -> &str {
126 &self.definition.title
127 }
128}
129
130impl<T> StoryTreeEntry for &T
131where
132 T: StoryTreeEntry + ?Sized,
133{
134 fn story_id(&self) -> &str {
135 (*self).story_id()
136 }
137
138 fn story_title(&self) -> &str {
139 (*self).story_title()
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn register_updates_count_and_manifest() {
149 let mut registry = ShowcaseRegistry::default();
150 registry.register(StoryEntry {
151 definition: StoryDefinition {
152 id: "atoms-button-default".to_owned(),
153 title: "Atoms/Button/Default".to_owned(),
154 source_path: "showcase/button.stories.rs".to_owned(),
155 module_path: "showcase::button_default".to_owned(),
156 renderer_symbol: "button_default".to_owned(),
157 tags: vec!["atoms".to_owned()],
158 },
159 renderer_symbol: "button_default",
160 });
161
162 assert_eq!(registry.story_count(), 1);
163 let manifest = registry.manifest();
164 assert_eq!(manifest.schema_version, 1);
165 assert_eq!(manifest.stories.len(), 1);
166 assert_eq!(manifest.stories[0].id, "atoms-button-default");
167 }
168
169 #[test]
170 fn build_story_navigation_groups_titles_into_tree() {
171 let stories = vec![
172 StoryEntry {
173 definition: StoryDefinition {
174 id: "atoms-dropdown-link".to_owned(),
175 title: "Atoms/Dropdown Link".to_owned(),
176 source_path: "showcase/button.stories.rs".to_owned(),
177 module_path: "showcase::dropdown_link".to_owned(),
178 renderer_symbol: "dropdown_link".to_owned(),
179 tags: vec![],
180 },
181 renderer_symbol: "dropdown_link",
182 },
183 StoryEntry {
184 definition: StoryDefinition {
185 id: "atoms-button".to_owned(),
186 title: "Atoms/Button".to_owned(),
187 source_path: "showcase/button.stories.rs".to_owned(),
188 module_path: "showcase::button".to_owned(),
189 renderer_symbol: "button".to_owned(),
190 tags: vec![],
191 },
192 renderer_symbol: "button",
193 },
194 ];
195
196 let navigation = build_story_navigation(&stories);
197 assert_eq!(navigation.len(), 1);
198 assert_eq!(navigation[0].segment, "Atoms");
199 assert_eq!(navigation[0].title_path, "Atoms");
200 assert_eq!(navigation[0].children.len(), 2);
201 assert_eq!(navigation[0].children[0].segment, "Dropdown Link");
202 assert_eq!(navigation[0].children[0].story_id.as_deref(), Some("atoms-dropdown-link"));
203 assert_eq!(navigation[0].children[1].segment, "Button");
204 assert_eq!(navigation[0].children[1].story_id.as_deref(), Some("atoms-button"));
205 }
206
207 #[test]
208 fn build_story_navigation_allows_branch_to_be_story_and_parent() {
209 let stories = vec![
210 StoryEntry {
211 definition: StoryDefinition {
212 id: "atoms".to_owned(),
213 title: "Atoms".to_owned(),
214 source_path: "showcase/atoms.rs".to_owned(),
215 module_path: "showcase::atoms".to_owned(),
216 renderer_symbol: "atoms".to_owned(),
217 tags: vec![],
218 },
219 renderer_symbol: "atoms",
220 },
221 StoryEntry {
222 definition: StoryDefinition {
223 id: "atoms-button".to_owned(),
224 title: "Atoms/Button".to_owned(),
225 source_path: "showcase/button.rs".to_owned(),
226 module_path: "showcase::button".to_owned(),
227 renderer_symbol: "button".to_owned(),
228 tags: vec![],
229 },
230 renderer_symbol: "button",
231 },
232 ];
233
234 let navigation = build_story_navigation(&stories);
235 assert_eq!(navigation.len(), 1);
236 assert_eq!(navigation[0].story_id.as_deref(), Some("atoms"));
237 assert_eq!(navigation[0].children.len(), 1);
238 assert_eq!(navigation[0].children[0].segment, "Button");
239 }
240
241 #[test]
242 fn build_story_navigation_accepts_slices_of_references() {
243 let stories = [StoryEntry {
244 definition: StoryDefinition {
245 id: "atoms-button".to_owned(),
246 title: "Atoms/Button".to_owned(),
247 source_path: "showcase/button.rs".to_owned(),
248 module_path: "showcase::button".to_owned(),
249 renderer_symbol: "button".to_owned(),
250 tags: vec![],
251 },
252 renderer_symbol: "button",
253 }];
254 let filtered = stories.iter().collect::<Vec<_>>();
255
256 let navigation = build_story_navigation(&filtered);
257
258 assert_eq!(navigation.len(), 1);
259 assert_eq!(navigation[0].segment, "Atoms");
260 assert_eq!(navigation[0].children.len(), 1);
261 assert_eq!(navigation[0].children[0].story_id.as_deref(), Some("atoms-button"));
262 }
263}