Skip to main content

hblank_core/
catalog.rs

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    /// Applies valid control values and ignores stale or invalid entries.
122    ///
123    /// Returns the number of values accepted by this fixture.
124    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    /// Replaces one generated property control value.
135    ///
136    /// # Errors
137    /// Returns an error when the identifier or value is invalid for this fixture's props.
138    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
207/// Joins framework adapter registrations into a deterministic component catalog.
208///
209/// # Errors
210/// Returns an error for duplicate identifiers, unknown components, or mismatched props types.
211pub 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}