1use std::{
2 any::TypeId,
3 collections::{HashMap, HashSet},
4 env,
5 path::Path,
6};
7
8use thiserror::Error;
9
10use crate::{ControlError, ControlValue, DocPage, HblankProps};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ComponentMetadata {
14 pub id: String,
15 pub title: &'static str,
16 pub group: &'static str,
17 pub docs: &'static str,
18 pub declaration: &'static str,
19 pub source: &'static str,
20 pub line: u32,
21}
22
23pub struct ComponentDefinition<Renderer> {
24 metadata: ComponentMetadata,
25 props_type: TypeId,
26 renderer: Renderer,
27 docs: DocPage,
28}
29
30impl<Renderer> ComponentDefinition<Renderer> {
31 #[must_use]
32 pub fn new<Props: HblankProps>(metadata: ComponentMetadata, renderer: Renderer) -> Self {
33 Self {
34 metadata,
35 props_type: TypeId::of::<Props>(),
36 renderer,
37 docs: DocPage::default(),
38 }
39 }
40
41 #[must_use]
42 pub const fn metadata(&self) -> &ComponentMetadata {
43 &self.metadata
44 }
45
46 #[must_use]
47 pub const fn renderer(&self) -> &Renderer {
48 &self.renderer
49 }
50
51 #[must_use]
52 pub fn with_docs(mut self, docs: DocPage) -> Self {
53 self.docs = docs;
54 self
55 }
56
57 #[must_use]
58 pub const fn docs(&self) -> &DocPage {
59 &self.docs
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct FixtureMetadata {
65 pub id: String,
66 pub component_id: String,
67 pub component_title: &'static str,
68 pub group: &'static str,
69 pub title: &'static str,
70 pub docs: &'static str,
71 pub declaration: &'static str,
72 pub source: &'static str,
73 pub line: u32,
74}
75
76pub struct FixtureRegistrationMetadata {
77 pub id: String,
78 pub title: &'static str,
79 pub docs: &'static str,
80 pub declaration: &'static str,
81 pub source: &'static str,
82 pub line: u32,
83}
84
85pub struct FixtureDefinition<Renderer> {
86 metadata: FixtureMetadata,
87 defaults: Box<dyn HblankProps>,
88 props: Box<dyn HblankProps>,
89 renderer: Renderer,
90}
91
92impl<Renderer> FixtureDefinition<Renderer> {
93 fn new(metadata: FixtureMetadata, props: Box<dyn HblankProps>, renderer: Renderer) -> Self {
94 Self {
95 metadata,
96 defaults: props.clone(),
97 props,
98 renderer,
99 }
100 }
101
102 #[must_use]
103 pub const fn metadata(&self) -> &FixtureMetadata {
104 &self.metadata
105 }
106
107 #[must_use]
108 pub fn props(&self) -> &dyn HblankProps {
109 self.props.as_ref()
110 }
111 #[must_use]
112 pub const fn renderer(&self) -> &Renderer {
113 &self.renderer
114 }
115
116 #[must_use]
117 pub fn default_control_value(&self, id: &str) -> Option<ControlValue> {
118 self.defaults.control_value(id)
119 }
120
121 pub fn apply_control_values<'a>(
125 &mut self,
126 values: impl IntoIterator<Item = (&'a str, &'a ControlValue)>,
127 ) -> usize {
128 values
129 .into_iter()
130 .filter(|(id, value)| self.set_control(id, (*value).clone()).is_ok())
131 .count()
132 }
133
134 pub fn set_control(&mut self, id: &str, value: ControlValue) -> Result<(), ControlError> {
139 self.props.set_control(id, value)
140 }
141
142 pub fn reset(&mut self) {
143 self.props = self.defaults.clone();
144 }
145}
146
147pub struct RegisteredCatalog<Renderer> {
148 components: Vec<ComponentDefinition<Renderer>>,
149 fixtures: Vec<FixtureDefinition<Renderer>>,
150}
151
152impl<Renderer> RegisteredCatalog<Renderer> {
153 #[must_use]
154 pub fn components(&self) -> &[ComponentDefinition<Renderer>] {
155 &self.components
156 }
157
158 #[must_use]
159 pub fn fixtures(&self) -> &[FixtureDefinition<Renderer>] {
160 &self.fixtures
161 }
162
163 #[must_use]
164 pub fn into_parts(
165 self,
166 ) -> (
167 Vec<ComponentDefinition<Renderer>>,
168 Vec<FixtureDefinition<Renderer>>,
169 ) {
170 (self.components, self.fixtures)
171 }
172}
173
174pub struct FixtureRegistrationData {
175 metadata: FixtureRegistrationMetadata,
176 component_id: String,
177 defaults: Box<dyn HblankProps>,
178}
179
180impl FixtureRegistrationData {
181 #[must_use]
182 pub fn new(
183 metadata: FixtureRegistrationMetadata,
184 component_id: String,
185 defaults: Box<dyn HblankProps>,
186 ) -> Self {
187 Self {
188 metadata,
189 component_id,
190 defaults,
191 }
192 }
193}
194
195#[derive(Debug, Error, PartialEq, Eq)]
196pub enum RegistryError {
197 #[error("multiple components use the id '{0}'")]
198 DuplicateComponentId(String),
199 #[error("multiple fixtures use the id '{0}'")]
200 DuplicateFixtureId(String),
201 #[error("fixture '{fixture}' references unknown component '{component}'")]
202 UnknownComponent { fixture: String, component: String },
203 #[error("fixture '{fixture}' uses props that do not match component '{component}'")]
204 PropsTypeMismatch { fixture: String, component: String },
205}
206
207pub fn assemble_catalog<Renderer: Copy>(
212 mut components: Vec<ComponentDefinition<Renderer>>,
213 registrations: Vec<FixtureRegistrationData>,
214) -> Result<RegisteredCatalog<Renderer>, RegistryError> {
215 components.sort_by(|left, right| {
216 let left = left.metadata();
217 let right = right.metadata();
218 (left.group, left.title, left.id.as_str()).cmp(&(
219 right.group,
220 right.title,
221 right.id.as_str(),
222 ))
223 });
224
225 let mut component_ids = HashSet::with_capacity(components.len());
226 for component in &components {
227 if !component_ids.insert(component.metadata.id.as_str()) {
228 return Err(RegistryError::DuplicateComponentId(
229 component.metadata.id.clone(),
230 ));
231 }
232 }
233 let component_indexes = components
234 .iter()
235 .enumerate()
236 .map(|(index, component)| (component.metadata.id.as_str(), index))
237 .collect::<HashMap<_, _>>();
238
239 let mut fixture_ids = HashSet::with_capacity(registrations.len());
240 let mut fixtures = Vec::with_capacity(registrations.len());
241 for registration in registrations {
242 if !fixture_ids.insert(registration.metadata.id.clone()) {
243 return Err(RegistryError::DuplicateFixtureId(registration.metadata.id));
244 }
245 let Some(&component_index) = component_indexes.get(registration.component_id.as_str())
246 else {
247 return Err(RegistryError::UnknownComponent {
248 fixture: registration.metadata.id,
249 component: registration.component_id,
250 });
251 };
252 let component = &components[component_index];
253 if registration.defaults.as_any().type_id() != component.props_type {
254 return Err(RegistryError::PropsTypeMismatch {
255 fixture: registration.metadata.id,
256 component: registration.component_id,
257 });
258 }
259 fixtures.push(FixtureDefinition::new(
260 FixtureMetadata {
261 id: registration.metadata.id,
262 component_id: component.metadata.id.clone(),
263 component_title: component.metadata.title,
264 group: component.metadata.group,
265 title: registration.metadata.title,
266 docs: registration.metadata.docs,
267 declaration: registration.metadata.declaration,
268 source: registration.metadata.source,
269 line: registration.metadata.line,
270 },
271 registration.defaults,
272 component.renderer,
273 ));
274 }
275 fixtures.sort_by(|left, right| {
276 let left = left.metadata();
277 let right = right.metadata();
278 (
279 left.group,
280 left.component_title,
281 left.title,
282 left.id.as_str(),
283 )
284 .cmp(&(
285 right.group,
286 right.component_title,
287 right.title,
288 right.id.as_str(),
289 ))
290 });
291
292 Ok(RegisteredCatalog {
293 components,
294 fixtures,
295 })
296}
297
298#[must_use]
299pub fn canonical_source_id(source: &str, symbol: &str) -> String {
300 let source = Path::new(source);
301 let project_root = env::var_os("HBLANK_PROJECT_ROOT")
302 .map(std::path::PathBuf::from)
303 .or_else(|| env::current_dir().ok());
304 let relative = project_root
305 .as_deref()
306 .and_then(|root| source.strip_prefix(root).ok())
307 .unwrap_or(source);
308 let portable = relative.to_string_lossy().replace('\\', "/");
309 format!("{portable}#{symbol}")
310}
311
312#[cfg(test)]
313mod tests {
314 use std::any::Any;
315
316 use super::*;
317 use crate::{ControlDefinition, ControlError, ControlKind, ControlValue};
318
319 #[derive(Clone)]
320 struct Props {
321 active: bool,
322 }
323
324 impl HblankProps for Props {
325 fn definitions(&self) -> &'static [ControlDefinition] {
326 &[ControlDefinition {
327 id: "active",
328 label: "Active",
329 docs: "",
330 kind: ControlKind::Boolean,
331 }]
332 }
333
334 fn control_value(&self, id: &str) -> Option<ControlValue> {
335 (id == "active").then_some(ControlValue::Boolean(self.active))
336 }
337
338 fn set_control(&mut self, id: &str, value: ControlValue) -> Result<(), ControlError> {
339 if id != "active" {
340 return Err(ControlError::UnknownControl(id.to_owned()));
341 }
342 let ControlValue::Boolean(value) = value else {
343 return Err(ControlError::TypeMismatch {
344 control: "active",
345 expected: "boolean",
346 actual: value.kind_name(),
347 });
348 };
349 self.active = value;
350 Ok(())
351 }
352
353 fn clone_box(&self) -> Box<dyn HblankProps> {
354 Box::new(self.clone())
355 }
356
357 fn as_any(&self) -> &dyn Any {
358 self
359 }
360 }
361
362 fn component() -> ComponentDefinition<&'static str> {
363 ComponentDefinition::new::<Props>(
364 ComponentMetadata {
365 id: "src/card.rs#card".to_owned(),
366 title: "Card",
367 group: "Components",
368 docs: "A card.",
369 declaration: "fn card(...) { ... }",
370 source: "src/card.rs",
371 line: 10,
372 },
373 "framework renderer",
374 )
375 }
376
377 fn fixture(component_id: &str) -> FixtureRegistrationData {
378 FixtureRegistrationData::new(
379 FixtureRegistrationMetadata {
380 id: "src/card.rs#default".to_owned(),
381 title: "Default",
382 docs: "",
383 declaration: "fn default() -> Props { ... }",
384 source: "src/card.rs",
385 line: 20,
386 },
387 component_id.to_owned(),
388 Box::new(Props { active: false }),
389 )
390 }
391
392 #[test]
393 fn assembles_catalog_without_framework_types() {
394 let catalog = assemble_catalog(vec![component()], vec![fixture("src/card.rs#card")])
395 .expect("framework-neutral registrations should assemble");
396
397 assert_eq!(catalog.components().len(), 1);
398 assert_eq!(catalog.fixtures().len(), 1);
399 assert_eq!(catalog.fixtures()[0].renderer(), &"framework renderer");
400 assert_eq!(catalog.fixtures()[0].metadata().component_title, "Card");
401 }
402
403 #[test]
404 fn rejects_unknown_framework_component_references() {
405 let Err(error) =
406 assemble_catalog::<&str>(Vec::new(), vec![fixture("src/missing.rs#missing")])
407 else {
408 panic!("unknown component should fail");
409 };
410
411 assert_eq!(
412 error,
413 RegistryError::UnknownComponent {
414 fixture: "src/card.rs#default".to_owned(),
415 component: "src/missing.rs#missing".to_owned(),
416 }
417 );
418 }
419
420 #[test]
421 fn reapplies_only_valid_session_control_values() {
422 let catalog = assemble_catalog(vec![component()], vec![fixture("src/card.rs#card")])
423 .expect("catalog should assemble");
424 let fixture = &mut catalog.into_parts().1.remove(0);
425 let values = [
426 ("active", ControlValue::Boolean(true)),
427 ("stale", ControlValue::Text("ignored".to_owned())),
428 ];
429
430 assert_eq!(
431 fixture.apply_control_values(values.iter().map(|(id, value)| (*id, value))),
432 1
433 );
434 assert_eq!(
435 fixture.props().control_value("active"),
436 Some(ControlValue::Boolean(true))
437 );
438 }
439
440 #[test]
441 fn builds_portable_source_symbol_ids() {
442 assert_eq!(
443 canonical_source_id("src/card.hblank.rs", "default"),
444 "src/card.hblank.rs#default"
445 );
446 }
447}