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