Skip to main content

alimentar/serve/
plugin.rs

1//! Plugin System - Extensible content type handling
2//!
3//! Provides a plugin architecture for extending alimentar with new content
4//! types. Built-in plugins include Dataset and Course (assetgen integration).
5
6use std::{collections::HashMap, sync::Arc};
7
8use arrow::{
9    array::{ArrayRef, RecordBatch, StringArray},
10    datatypes::{DataType, Field, Schema},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    dataset::{ArrowDataset, Dataset},
16    error::{Error, Result},
17    serve::{
18        content::{
19            BoxedContent, ContentMetadata, ContentTypeId, ServeableContent, ValidationReport,
20        },
21        schema::{Constraint, ContentSchema, FieldDefinition, FieldType},
22    },
23};
24
25/// Rendering hints for trueno-viz integration
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct RenderHints {
28    /// Preferred chart type (e.g., "scatter", "line", "table")
29    pub chart_type: Option<String>,
30    /// X-axis column name
31    pub x_column: Option<String>,
32    /// Y-axis column name
33    pub y_column: Option<String>,
34    /// Color column name
35    pub color_column: Option<String>,
36    /// Additional rendering options
37    #[serde(default)]
38    pub options: HashMap<String, serde_json::Value>,
39}
40
41impl RenderHints {
42    /// Create new render hints
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Set chart type
48    pub fn with_chart_type(mut self, chart_type: impl Into<String>) -> Self {
49        self.chart_type = Some(chart_type.into());
50        self
51    }
52
53    /// Set X column
54    pub fn with_x_column(mut self, column: impl Into<String>) -> Self {
55        self.x_column = Some(column.into());
56        self
57    }
58
59    /// Set Y column
60    pub fn with_y_column(mut self, column: impl Into<String>) -> Self {
61        self.y_column = Some(column.into());
62        self
63    }
64
65    /// Set color column
66    pub fn with_color_column(mut self, column: impl Into<String>) -> Self {
67        self.color_column = Some(column.into());
68        self
69    }
70
71    /// Add a custom option
72    pub fn with_option(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
73        self.options.insert(key.into(), value);
74        self
75    }
76}
77
78/// Plugin interface for extending alimentar with new content types
79///
80/// Implement this trait to add support for custom content types
81/// that can be served, validated, and visualized.
82pub trait ContentPlugin: Send + Sync {
83    /// Returns the content type ID this plugin handles
84    fn content_type(&self) -> ContentTypeId;
85
86    /// Returns the schema for this content type
87    fn schema(&self) -> ContentSchema;
88
89    /// Parses raw content into ServeableContent
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the data cannot be parsed into the expected content
94    /// type.
95    fn parse(&self, data: &[u8]) -> Result<BoxedContent>;
96
97    /// Serializes ServeableContent back to bytes
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the content cannot be serialized.
102    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>>;
103
104    /// Returns UI rendering hints for trueno-viz integration
105    fn render_hints(&self) -> RenderHints;
106
107    /// Plugin version for compatibility
108    fn version(&self) -> &str;
109
110    /// Plugin name for display
111    fn name(&self) -> &str;
112
113    /// Plugin description
114    fn description(&self) -> &str;
115}
116
117/// Registry for content plugins
118pub struct PluginRegistry {
119    plugins: HashMap<ContentTypeId, Box<dyn ContentPlugin>>,
120}
121
122impl PluginRegistry {
123    /// Create a new plugin registry with built-in plugins
124    pub fn new() -> Self {
125        let mut registry = Self {
126            plugins: HashMap::new(),
127        };
128
129        // Register built-in plugins
130        registry.register(Box::new(DatasetPlugin::new()));
131        registry.register(Box::new(RawPlugin::new()));
132
133        registry
134    }
135
136    /// Register a plugin
137    pub fn register(&mut self, plugin: Box<dyn ContentPlugin>) {
138        self.plugins.insert(plugin.content_type(), plugin);
139    }
140
141    /// Get a plugin by content type
142    pub fn get(&self, content_type: &ContentTypeId) -> Option<&dyn ContentPlugin> {
143        self.plugins.get(content_type).map(|p| p.as_ref())
144    }
145
146    /// List all registered content types
147    pub fn content_types(&self) -> Vec<ContentTypeId> {
148        self.plugins.keys().cloned().collect()
149    }
150
151    /// Check if a content type is registered
152    pub fn has(&self, content_type: &ContentTypeId) -> bool {
153        self.plugins.contains_key(content_type)
154    }
155
156    /// Get plugin count
157    pub fn len(&self) -> usize {
158        self.plugins.len()
159    }
160
161    /// Check if registry is empty
162    pub fn is_empty(&self) -> bool {
163        self.plugins.is_empty()
164    }
165}
166
167impl Default for PluginRegistry {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173// ============================================================================
174// Built-in Plugins
175// ============================================================================
176
177/// Dataset plugin for Arrow/Parquet datasets
178pub struct DatasetPlugin;
179
180impl DatasetPlugin {
181    /// Create a new dataset plugin
182    pub fn new() -> Self {
183        Self
184    }
185}
186
187impl Default for DatasetPlugin {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl ContentPlugin for DatasetPlugin {
194    fn content_type(&self) -> ContentTypeId {
195        ContentTypeId::dataset()
196    }
197
198    fn schema(&self) -> ContentSchema {
199        ContentSchema::new(ContentTypeId::dataset(), "1.0")
200            .with_field(
201                FieldDefinition::new("name", FieldType::String)
202                    .with_description("Dataset name")
203                    .with_constraint(Constraint::min_length(1)),
204            )
205            .with_field(
206                FieldDefinition::new("format", FieldType::String)
207                    .with_description("Data format (parquet, csv, json)")
208                    .with_constraint(Constraint::enum_values(vec![
209                        serde_json::json!("parquet"),
210                        serde_json::json!("csv"),
211                        serde_json::json!("json"),
212                        serde_json::json!("arrow"),
213                    ])),
214            )
215            .with_field(
216                FieldDefinition::new("rows", FieldType::Integer).with_description("Number of rows"),
217            )
218            .with_field(
219                FieldDefinition::new("columns", FieldType::Integer)
220                    .with_description("Number of columns"),
221            )
222            .with_required("name")
223    }
224
225    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
226        // Contract: configuration-v1.yaml precondition (pv codegen)
227        contract_pre_configuration!(data);
228        // Try to parse as parquet first
229        if data.len() >= 4 && &data[0..4] == b"PAR1" {
230            let dataset = ArrowDataset::from_parquet_bytes(data)?;
231            return Ok(Box::new(DatasetContent::new(dataset)));
232        }
233
234        // Try to parse as JSON
235        if let Ok(text) = std::str::from_utf8(data) {
236            let trimmed = text.trim();
237            if trimmed.starts_with('{') || trimmed.starts_with('[') {
238                let dataset = ArrowDataset::from_json_str(text)?;
239                return Ok(Box::new(DatasetContent::new(dataset)));
240            }
241
242            // Try CSV
243            let dataset = ArrowDataset::from_csv_str(text)?;
244            return Ok(Box::new(DatasetContent::new(dataset)));
245        }
246
247        Err(Error::parse("Unable to detect dataset format"))
248    }
249
250    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
251        content.to_bytes()
252    }
253
254    fn render_hints(&self) -> RenderHints {
255        RenderHints::new().with_chart_type("table")
256    }
257
258    fn version(&self) -> &'static str {
259        "1.0.0"
260    }
261
262    fn name(&self) -> &'static str {
263        "Dataset Plugin"
264    }
265
266    fn description(&self) -> &'static str {
267        "Handles Arrow/Parquet/CSV/JSON datasets"
268    }
269}
270
271/// Wrapper for ArrowDataset as ServeableContent
272struct DatasetContent {
273    dataset: ArrowDataset,
274    name: String,
275}
276
277impl DatasetContent {
278    fn new(dataset: ArrowDataset) -> Self {
279        Self {
280            dataset,
281            name: "dataset".to_string(),
282        }
283    }
284
285    #[allow(dead_code)]
286    fn with_name(mut self, name: impl Into<String>) -> Self {
287        self.name = name.into();
288        self
289    }
290}
291
292impl ServeableContent for DatasetContent {
293    fn schema(&self) -> ContentSchema {
294        ContentSchema::new(ContentTypeId::dataset(), "1.0")
295    }
296
297    fn validate(&self) -> Result<ValidationReport> {
298        Ok(ValidationReport::success())
299    }
300
301    fn to_arrow(&self) -> Result<RecordBatch> {
302        self.dataset
303            .get(0)
304            .ok_or_else(|| Error::data("Empty dataset"))
305    }
306
307    fn metadata(&self) -> ContentMetadata {
308        ContentMetadata::new(ContentTypeId::dataset(), &self.name, 0)
309            .with_row_count(self.dataset.len())
310    }
311
312    fn content_type(&self) -> ContentTypeId {
313        ContentTypeId::dataset()
314    }
315
316    fn chunks(&self, _chunk_size: usize) -> Box<dyn Iterator<Item = Result<RecordBatch>> + Send> {
317        let batches: Vec<_> = self.dataset.iter().collect();
318        Box::new(batches.into_iter().map(Ok))
319    }
320
321    fn to_bytes(&self) -> Result<Vec<u8>> {
322        // Return parquet bytes
323        self.dataset.to_parquet_bytes()
324    }
325}
326
327/// Raw data plugin for pasted/clipboard content
328pub struct RawPlugin;
329
330impl RawPlugin {
331    /// Create a new raw plugin
332    pub fn new() -> Self {
333        Self
334    }
335}
336
337impl Default for RawPlugin {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343impl ContentPlugin for RawPlugin {
344    fn content_type(&self) -> ContentTypeId {
345        ContentTypeId::raw()
346    }
347
348    fn schema(&self) -> ContentSchema {
349        ContentSchema::new(ContentTypeId::raw(), "1.0")
350            .with_field(
351                FieldDefinition::new("data", FieldType::String)
352                    .with_description("Raw data content"),
353            )
354            .with_field(
355                FieldDefinition::new("source", FieldType::String)
356                    .with_description("Data source (clipboard, stdin, etc.)"),
357            )
358            .with_field(
359                FieldDefinition::new("format", FieldType::String)
360                    .with_description("Detected format"),
361            )
362    }
363
364    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
365        use crate::serve::raw_source::{RawSource, SourceType};
366
367        let text =
368            std::str::from_utf8(data).map_err(|e| Error::parse(format!("Invalid UTF-8: {e}")))?;
369
370        let source = RawSource::from_string(text, SourceType::Direct);
371        Ok(Box::new(source))
372    }
373
374    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
375        content.to_bytes()
376    }
377
378    fn render_hints(&self) -> RenderHints {
379        RenderHints::new().with_chart_type("table")
380    }
381
382    fn version(&self) -> &'static str {
383        "1.0.0"
384    }
385
386    fn name(&self) -> &'static str {
387        "Raw Data Plugin"
388    }
389
390    fn description(&self) -> &'static str {
391        "Handles raw/pasted data with automatic format detection"
392    }
393}
394
395// ============================================================================
396// Course Plugin (assetgen integration)
397// ============================================================================
398
399/// Course plugin for assetgen course content
400#[allow(dead_code)]
401pub struct CoursePlugin;
402
403impl CoursePlugin {
404    /// Create a new course plugin
405    pub fn new() -> Self {
406        Self
407    }
408}
409
410impl Default for CoursePlugin {
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416impl ContentPlugin for CoursePlugin {
417    fn content_type(&self) -> ContentTypeId {
418        ContentTypeId::course()
419    }
420
421    fn schema(&self) -> ContentSchema {
422        ContentSchema::new(ContentTypeId::course(), "1.0")
423            .with_field(
424                FieldDefinition::new("id", FieldType::String)
425                    .with_description("Unique course identifier")
426                    .with_constraint(Constraint::pattern(r"^[a-z0-9-]+$"))
427                    .with_constraint(Constraint::max_length(64)),
428            )
429            .with_field(
430                FieldDefinition::new("title", FieldType::String)
431                    .with_description("Course title")
432                    .with_constraint(Constraint::min_length(1))
433                    .with_constraint(Constraint::max_length(256)),
434            )
435            .with_field(
436                FieldDefinition::new("description", FieldType::String)
437                    .with_description("Full course description"),
438            )
439            .with_field(
440                FieldDefinition::new("short_description", FieldType::String)
441                    .with_description("Brief course summary")
442                    .with_constraint(Constraint::max_length(500)),
443            )
444            .with_field(
445                FieldDefinition::new("categories", FieldType::array(FieldType::String))
446                    .with_description("Course categories"),
447            )
448            .with_field(
449                FieldDefinition::new("weeks", FieldType::Integer)
450                    .with_description("Number of weeks")
451                    .with_constraint(Constraint::min(1.0))
452                    .with_constraint(Constraint::max(52.0)),
453            )
454            .with_field(
455                FieldDefinition::new("featured", FieldType::Boolean)
456                    .with_description("Whether course is featured")
457                    .with_default(serde_json::json!(false)),
458            )
459            .with_required("id")
460            .with_required("title")
461            .with_required("description")
462            .with_required("weeks")
463    }
464
465    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
466        let text =
467            std::str::from_utf8(data).map_err(|e| Error::parse(format!("Invalid UTF-8: {e}")))?;
468
469        // Parse as JSON (course outline format)
470        let course: CourseContent = serde_json::from_str(text)
471            .map_err(|e| Error::parse(format!("Invalid course JSON: {e}")))?;
472
473        Ok(Box::new(course))
474    }
475
476    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
477        content.to_bytes()
478    }
479
480    fn render_hints(&self) -> RenderHints {
481        RenderHints::new()
482            .with_chart_type("course")
483            .with_option("show_progress", serde_json::json!(true))
484    }
485
486    fn version(&self) -> &'static str {
487        "1.0.0"
488    }
489
490    fn name(&self) -> &'static str {
491        "Course Plugin"
492    }
493
494    fn description(&self) -> &'static str {
495        "Handles assetgen course content"
496    }
497}
498
499/// Course content structure (aligned with assetgen)
500#[allow(dead_code)]
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct CourseContent {
503    /// Course ID
504    pub id: String,
505    /// Course title
506    pub title: String,
507    /// Full description
508    pub description: String,
509    /// Short description
510    #[serde(default)]
511    pub short_description: String,
512    /// Categories
513    #[serde(default)]
514    pub categories: Vec<String>,
515    /// Number of weeks
516    pub weeks: u32,
517    /// Featured flag
518    #[serde(default)]
519    pub featured: bool,
520    /// Difficulty level
521    #[serde(default)]
522    pub difficulty: Option<String>,
523    /// Prerequisites
524    #[serde(default)]
525    pub prerequisites: Vec<String>,
526    /// Course outline
527    #[serde(default)]
528    pub outline: Option<CourseOutline>,
529}
530
531/// Course outline structure
532#[allow(dead_code)]
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct CourseOutline {
535    /// Outline title
536    pub title: String,
537    /// Weeks in the course
538    #[serde(default)]
539    pub weeks: Vec<Week>,
540}
541
542/// Week structure
543#[allow(dead_code)]
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct Week {
546    /// Week number
547    pub number: u32,
548    /// Week title
549    pub title: String,
550    /// Lessons in this week
551    #[serde(default)]
552    pub lessons: Vec<Lesson>,
553}
554
555/// Lesson structure
556#[allow(dead_code)]
557#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct Lesson {
559    /// Lesson number (e.g., "1.1", "1.2")
560    pub number: String,
561    /// Lesson title
562    pub title: String,
563    /// Lesson assets
564    #[serde(default)]
565    pub assets: Vec<Asset>,
566}
567
568/// Asset structure
569#[allow(dead_code)]
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct Asset {
572    /// Asset filename
573    pub filename: String,
574    /// Asset type
575    #[serde(rename = "type")]
576    pub kind: AssetType,
577    /// Asset description
578    #[serde(default)]
579    pub description: Option<String>,
580}
581
582/// Asset type enumeration
583#[allow(dead_code)]
584#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
585#[serde(rename_all = "lowercase")]
586pub enum AssetType {
587    /// Video asset
588    Video,
589    /// Key terms asset
590    KeyTerms,
591    /// Quiz asset
592    Quiz,
593    /// Lab asset
594    Lab,
595    /// Reflection asset
596    Reflection,
597}
598
599impl ServeableContent for CourseContent {
600    fn schema(&self) -> ContentSchema {
601        CoursePlugin::new().schema()
602    }
603
604    fn validate(&self) -> Result<ValidationReport> {
605        let mut report = ValidationReport::success();
606
607        if self.id.is_empty() {
608            report = report.with_error(crate::serve::content::ValidationError::new(
609                "id",
610                "Course ID is required",
611            ));
612        }
613
614        if self.title.is_empty() {
615            report = report.with_error(crate::serve::content::ValidationError::new(
616                "title",
617                "Course title is required",
618            ));
619        }
620
621        if self.weeks == 0 {
622            report = report.with_error(crate::serve::content::ValidationError::new(
623                "weeks",
624                "Course must have at least 1 week",
625            ));
626        }
627
628        Ok(report)
629    }
630
631    fn to_arrow(&self) -> Result<RecordBatch> {
632        // Convert course to a simple table representation
633        let schema = Arc::new(Schema::new(vec![
634            Field::new("id", DataType::Utf8, false),
635            Field::new("title", DataType::Utf8, false),
636            Field::new("description", DataType::Utf8, false),
637            Field::new("weeks", DataType::Utf8, false),
638            Field::new("categories", DataType::Utf8, false),
639        ]));
640
641        let id_array: ArrayRef = Arc::new(StringArray::from(vec![self.id.as_str()]));
642        let title_array: ArrayRef = Arc::new(StringArray::from(vec![self.title.as_str()]));
643        let desc_array: ArrayRef = Arc::new(StringArray::from(vec![self.description.as_str()]));
644        let weeks_array: ArrayRef = Arc::new(StringArray::from(vec![self.weeks.to_string()]));
645        let cats_array: ArrayRef = Arc::new(StringArray::from(vec![self.categories.join(", ")]));
646
647        RecordBatch::try_new(
648            schema,
649            vec![id_array, title_array, desc_array, weeks_array, cats_array],
650        )
651        .map_err(|e| Error::data(format!("Failed to create course batch: {e}")))
652    }
653
654    fn metadata(&self) -> ContentMetadata {
655        ContentMetadata::new(ContentTypeId::course(), &self.title, 0)
656            .with_description(&self.description)
657            .with_custom("id", serde_json::json!(&self.id))
658            .with_custom("weeks", serde_json::json!(self.weeks))
659            .with_custom("categories", serde_json::json!(&self.categories))
660    }
661
662    fn content_type(&self) -> ContentTypeId {
663        ContentTypeId::course()
664    }
665
666    fn chunks(&self, _chunk_size: usize) -> Box<dyn Iterator<Item = Result<RecordBatch>> + Send> {
667        let batch_result = self.to_arrow();
668        Box::new(std::iter::once(batch_result))
669    }
670
671    fn to_bytes(&self) -> Result<Vec<u8>> {
672        serde_json::to_vec(self)
673            .map_err(|e| Error::data(format!("Failed to serialize course: {e}")))
674    }
675}
676
677// ============================================================================
678// Book Plugin (assetgen book/chapter integration)
679// ============================================================================
680
681/// Book plugin for assetgen book content with chapters
682#[allow(dead_code)]
683pub struct BookPlugin;
684
685impl BookPlugin {
686    /// Create a new book plugin
687    pub fn new() -> Self {
688        Self
689    }
690}
691
692impl Default for BookPlugin {
693    fn default() -> Self {
694        Self::new()
695    }
696}
697
698impl ContentPlugin for BookPlugin {
699    fn content_type(&self) -> ContentTypeId {
700        ContentTypeId::new("assetgen.book")
701    }
702
703    fn schema(&self) -> ContentSchema {
704        ContentSchema::new(ContentTypeId::new("assetgen.book"), "1.0")
705            .with_field(
706                FieldDefinition::new("id", FieldType::String).with_description("Book identifier"),
707            )
708            .with_field(
709                FieldDefinition::new("title", FieldType::String).with_description("Book title"),
710            )
711            .with_field(
712                FieldDefinition::new("author", FieldType::String).with_description("Book author"),
713            )
714            .with_field(
715                FieldDefinition::new("description", FieldType::String)
716                    .with_description("Book description"),
717            )
718            .with_field(
719                FieldDefinition::new("version", FieldType::String).with_description("Book version"),
720            )
721            .with_field(
722                FieldDefinition::new(
723                    "chapters",
724                    FieldType::array(FieldType::Object {
725                        schema: Box::new(ContentSchema::new(
726                            ContentTypeId::new("assetgen.chapter"),
727                            "1.0",
728                        )),
729                    }),
730                )
731                .with_description("Book chapters"),
732            )
733            .with_required("id")
734            .with_required("title")
735    }
736
737    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
738        let text =
739            std::str::from_utf8(data).map_err(|e| Error::parse(format!("Invalid UTF-8: {e}")))?;
740
741        let book: BookContent = serde_json::from_str(text)
742            .map_err(|e| Error::parse(format!("Invalid book JSON: {e}")))?;
743
744        Ok(Box::new(book))
745    }
746
747    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
748        content.to_bytes()
749    }
750
751    fn render_hints(&self) -> RenderHints {
752        RenderHints::new()
753            .with_chart_type("book")
754            .with_option("show_progress", serde_json::json!(true))
755            .with_option("enable_bookmarks", serde_json::json!(true))
756    }
757
758    fn version(&self) -> &'static str {
759        "1.0.0"
760    }
761
762    fn name(&self) -> &'static str {
763        "Book Plugin"
764    }
765
766    fn description(&self) -> &'static str {
767        "Handles assetgen book content with chapters"
768    }
769}
770
771/// Book content structure (aligned with assetgen books)
772#[allow(dead_code)]
773#[derive(Debug, Clone, Serialize, Deserialize)]
774pub struct BookContent {
775    /// Book ID
776    pub id: String,
777    /// Book title
778    pub title: String,
779    /// Book author
780    pub author: String,
781    /// Book description
782    #[serde(default)]
783    pub description: String,
784    /// Book version
785    #[serde(default)]
786    pub version: String,
787    /// Source URL
788    #[serde(default)]
789    pub source_url: Option<String>,
790    /// Book settings
791    #[serde(default)]
792    pub settings: Option<BookSettings>,
793    /// Book chapters
794    #[serde(default)]
795    pub chapters: Vec<Chapter>,
796}
797
798/// Book feature enumeration
799#[allow(dead_code)]
800#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
801#[serde(rename_all = "lowercase")]
802pub enum BookFeature {
803    /// Enable terminal component
804    Terminal,
805    /// Enable quizzes
806    Quizzes,
807    /// Enable labs
808    Labs,
809    /// Show progress indicator
810    Progress,
811    /// Enable bookmarks
812    Bookmarks,
813}
814
815/// Book settings
816#[allow(dead_code)]
817#[derive(Debug, Clone, Serialize, Deserialize, Default)]
818pub struct BookSettings {
819    /// Enabled features (set of feature flags)
820    #[serde(default)]
821    pub features: std::collections::HashSet<BookFeature>,
822    /// Default Python version
823    #[serde(default)]
824    pub default_python_version: Option<String>,
825    /// Required packages
826    #[serde(default)]
827    pub required_packages: Vec<String>,
828    /// Navigation type (sequential, free)
829    #[serde(default)]
830    pub navigation_type: Option<String>,
831}
832
833/// Chapter structure
834#[allow(dead_code)]
835#[derive(Debug, Clone, Serialize, Deserialize)]
836pub struct Chapter {
837    /// Chapter ID
838    pub id: String,
839    /// Chapter title
840    pub title: String,
841    /// Chapter order
842    #[serde(default)]
843    pub order: u32,
844    /// Source file path
845    #[serde(default)]
846    pub source_file: Option<String>,
847    /// Interactive components
848    #[serde(default)]
849    pub components: Vec<ChapterComponent>,
850    /// Chapter settings
851    #[serde(default)]
852    pub settings: Option<ChapterSettings>,
853}
854
855/// Chapter settings
856#[allow(dead_code)]
857#[derive(Debug, Clone, Serialize, Deserialize, Default)]
858pub struct ChapterSettings {
859    /// Estimated reading time
860    #[serde(default)]
861    pub estimated_time: Option<String>,
862    /// Difficulty level
863    #[serde(default)]
864    pub difficulty: Option<String>,
865    /// Prerequisites (other chapter IDs)
866    #[serde(default)]
867    pub prerequisites: Vec<String>,
868}
869
870/// Interactive chapter component
871#[allow(dead_code)]
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct ChapterComponent {
874    /// Component type (terminal, quiz, lab)
875    #[serde(rename = "type")]
876    pub kind: ComponentType,
877    /// Component ID
878    pub id: String,
879    /// Position in chapter
880    #[serde(default)]
881    pub position: Option<String>,
882    /// Component configuration
883    #[serde(default)]
884    pub config: Option<serde_json::Value>,
885}
886
887/// Component type enumeration
888#[allow(dead_code)]
889#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
890#[serde(rename_all = "lowercase")]
891pub enum ComponentType {
892    /// Interactive terminal
893    Terminal,
894    /// Quiz component
895    Quiz,
896    /// Lab exercise
897    Lab,
898    /// Code editor
899    Editor,
900    /// Visualization
901    Visualization,
902}
903
904impl ServeableContent for BookContent {
905    fn schema(&self) -> ContentSchema {
906        BookPlugin::new().schema()
907    }
908
909    fn validate(&self) -> Result<ValidationReport> {
910        let mut report = ValidationReport::success();
911
912        if self.id.is_empty() {
913            report = report.with_error(crate::serve::content::ValidationError::new(
914                "id",
915                "Book ID is required",
916            ));
917        }
918
919        if self.title.is_empty() {
920            report = report.with_error(crate::serve::content::ValidationError::new(
921                "title",
922                "Book title is required",
923            ));
924        }
925
926        if self.author.is_empty() {
927            report = report.with_error(crate::serve::content::ValidationError::new(
928                "author",
929                "Book author is required",
930            ));
931        }
932
933        // Validate chapters
934        for (i, chapter) in self.chapters.iter().enumerate() {
935            if chapter.id.is_empty() {
936                report = report.with_error(crate::serve::content::ValidationError::new(
937                    format!("chapters[{}].id", i),
938                    "Chapter ID is required",
939                ));
940            }
941            if chapter.title.is_empty() {
942                report = report.with_error(crate::serve::content::ValidationError::new(
943                    format!("chapters[{}].title", i),
944                    "Chapter title is required",
945                ));
946            }
947        }
948
949        Ok(report)
950    }
951
952    fn to_arrow(&self) -> Result<RecordBatch> {
953        // Convert book chapters to a table representation
954        let schema = Arc::new(Schema::new(vec![
955            Field::new("chapter_id", DataType::Utf8, false),
956            Field::new("chapter_title", DataType::Utf8, false),
957            Field::new("order", DataType::Utf8, false),
958            Field::new("difficulty", DataType::Utf8, true),
959            Field::new("estimated_time", DataType::Utf8, true),
960        ]));
961
962        if self.chapters.is_empty() {
963            // Return empty batch with schema
964            return Ok(RecordBatch::new_empty(schema));
965        }
966
967        let chapter_ids: Vec<&str> = self.chapters.iter().map(|c| c.id.as_str()).collect();
968        let chapter_titles: Vec<&str> = self.chapters.iter().map(|c| c.title.as_str()).collect();
969        let orders: Vec<String> = self.chapters.iter().map(|c| c.order.to_string()).collect();
970        let difficulties: Vec<Option<&str>> = self
971            .chapters
972            .iter()
973            .map(|c| c.settings.as_ref().and_then(|s| s.difficulty.as_deref()))
974            .collect();
975        let times: Vec<Option<&str>> = self
976            .chapters
977            .iter()
978            .map(|c| {
979                c.settings
980                    .as_ref()
981                    .and_then(|s| s.estimated_time.as_deref())
982            })
983            .collect();
984
985        let id_array: ArrayRef = Arc::new(StringArray::from(chapter_ids));
986        let title_array: ArrayRef = Arc::new(StringArray::from(chapter_titles));
987        let order_array: ArrayRef = Arc::new(StringArray::from(orders));
988        let diff_array: ArrayRef = Arc::new(StringArray::from(difficulties));
989        let time_array: ArrayRef = Arc::new(StringArray::from(times));
990
991        RecordBatch::try_new(
992            schema,
993            vec![id_array, title_array, order_array, diff_array, time_array],
994        )
995        .map_err(|e| Error::data(format!("Failed to create book batch: {e}")))
996    }
997
998    fn metadata(&self) -> ContentMetadata {
999        ContentMetadata::new(ContentTypeId::new("assetgen.book"), &self.title, 0)
1000            .with_description(&self.description)
1001            .with_custom("id", serde_json::json!(&self.id))
1002            .with_custom("author", serde_json::json!(&self.author))
1003            .with_custom("version", serde_json::json!(&self.version))
1004            .with_custom("chapter_count", serde_json::json!(self.chapters.len()))
1005    }
1006
1007    fn content_type(&self) -> ContentTypeId {
1008        ContentTypeId::new("assetgen.book")
1009    }
1010
1011    fn chunks(&self, _chunk_size: usize) -> Box<dyn Iterator<Item = Result<RecordBatch>> + Send> {
1012        let batch_result = self.to_arrow();
1013        Box::new(std::iter::once(batch_result))
1014    }
1015
1016    fn to_bytes(&self) -> Result<Vec<u8>> {
1017        serde_json::to_vec(self).map_err(|e| Error::data(format!("Failed to serialize book: {e}")))
1018    }
1019}
1020
1021#[cfg(test)]
1022#[allow(clippy::unwrap_used, clippy::manual_string_new)]
1023mod tests {
1024    use super::*;
1025
1026    #[test]
1027    fn test_plugin_registry() {
1028        let registry = PluginRegistry::new();
1029
1030        assert!(registry.has(&ContentTypeId::dataset()));
1031        assert!(registry.has(&ContentTypeId::raw()));
1032        assert!(!registry.has(&ContentTypeId::course()));
1033    }
1034
1035    #[test]
1036    fn test_register_course_plugin() {
1037        let mut registry = PluginRegistry::new();
1038        registry.register(Box::new(CoursePlugin::new()));
1039
1040        assert!(registry.has(&ContentTypeId::course()));
1041        assert_eq!(registry.len(), 3);
1042    }
1043
1044    #[test]
1045    fn test_dataset_plugin_schema() {
1046        let plugin = DatasetPlugin::new();
1047        let schema = plugin.schema();
1048
1049        assert_eq!(schema.content_type, ContentTypeId::dataset());
1050        assert!(schema.is_required("name"));
1051    }
1052
1053    #[test]
1054    fn test_course_plugin_schema() {
1055        let plugin = CoursePlugin::new();
1056        let schema = plugin.schema();
1057
1058        assert_eq!(schema.content_type, ContentTypeId::course());
1059        assert!(schema.is_required("id"));
1060        assert!(schema.is_required("title"));
1061    }
1062
1063    #[test]
1064    fn test_render_hints() {
1065        let hints = RenderHints::new()
1066            .with_chart_type("scatter")
1067            .with_x_column("x")
1068            .with_y_column("y")
1069            .with_option("point_size", serde_json::json!(5));
1070
1071        assert_eq!(hints.chart_type, Some("scatter".to_string()));
1072        assert_eq!(hints.x_column, Some("x".to_string()));
1073        assert!(hints.options.contains_key("point_size"));
1074    }
1075
1076    #[test]
1077    fn test_course_content_validation() {
1078        let valid_course = CourseContent {
1079            id: "rust-fundamentals".to_string(),
1080            title: "Rust Fundamentals".to_string(),
1081            description: "Learn Rust programming".to_string(),
1082            short_description: "Learn Rust".to_string(),
1083            categories: vec!["programming".to_string()],
1084            weeks: 4,
1085            featured: false,
1086            difficulty: Some("beginner".to_string()),
1087            prerequisites: vec![],
1088            outline: None,
1089        };
1090
1091        let report = valid_course.validate().unwrap();
1092        assert!(report.valid);
1093
1094        let invalid_course = CourseContent {
1095            id: "".to_string(),
1096            title: "".to_string(),
1097            description: "".to_string(),
1098            short_description: "".to_string(),
1099            categories: vec![],
1100            weeks: 0,
1101            featured: false,
1102            difficulty: None,
1103            prerequisites: vec![],
1104            outline: None,
1105        };
1106
1107        let report = invalid_course.validate().unwrap();
1108        assert!(!report.valid);
1109        assert!(!report.errors.is_empty());
1110    }
1111
1112    #[test]
1113    fn test_course_to_arrow() {
1114        let course = CourseContent {
1115            id: "test-course".to_string(),
1116            title: "Test Course".to_string(),
1117            description: "A test course".to_string(),
1118            short_description: "Test".to_string(),
1119            categories: vec!["test".to_string()],
1120            weeks: 2,
1121            featured: false,
1122            difficulty: None,
1123            prerequisites: vec![],
1124            outline: None,
1125        };
1126
1127        let batch = course.to_arrow().unwrap();
1128        assert_eq!(batch.num_rows(), 1);
1129        assert_eq!(batch.num_columns(), 5);
1130    }
1131
1132    #[test]
1133    fn test_dataset_plugin_parse_csv() {
1134        let plugin = DatasetPlugin::new();
1135        let csv_data = b"name,age\nAlice,30\nBob,25";
1136        let content = plugin.parse(csv_data).unwrap();
1137        let batch = content.to_arrow().unwrap();
1138        // CSV with header has 2 data rows
1139        assert!(batch.num_rows() >= 1);
1140        assert!(batch.num_columns() >= 1);
1141    }
1142
1143    #[test]
1144    fn test_dataset_plugin_parse_json() {
1145        let plugin = DatasetPlugin::new();
1146        // Arrow JSON reader expects newline-delimited JSON objects
1147        let json_data = b"{\"name\":\"Alice\",\"age\":30}\n{\"name\":\"Bob\",\"age\":25}";
1148        let content = plugin.parse(json_data).unwrap();
1149        let batch = content.to_arrow().unwrap();
1150        assert!(batch.num_rows() >= 1);
1151    }
1152
1153    #[test]
1154    fn test_dataset_plugin_serialize() {
1155        use crate::ArrowDataset;
1156
1157        let csv_data = "name,value\na,1\nb,2";
1158        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1159        let content = DatasetContent::new(dataset);
1160
1161        let plugin = DatasetPlugin::new();
1162        let bytes = plugin.serialize(&content).unwrap();
1163        assert!(!bytes.is_empty());
1164    }
1165
1166    #[test]
1167    fn test_dataset_plugin_version_and_name() {
1168        let plugin = DatasetPlugin::new();
1169        assert_eq!(plugin.version(), "1.0.0");
1170        assert_eq!(plugin.name(), "Dataset Plugin");
1171        assert!(!plugin.description().is_empty());
1172    }
1173
1174    #[test]
1175    fn test_dataset_content_metadata() {
1176        use crate::ArrowDataset;
1177
1178        let csv_data = "name,value\na,1\nb,2";
1179        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1180        let content = DatasetContent::new(dataset);
1181
1182        let meta = content.metadata();
1183        assert_eq!(meta.content_type, ContentTypeId::dataset());
1184        assert_eq!(meta.row_count, Some(2));
1185    }
1186
1187    #[test]
1188    fn test_dataset_content_chunks() {
1189        use crate::ArrowDataset;
1190
1191        let csv_data = "name,value\na,1\nb,2";
1192        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1193        let content = DatasetContent::new(dataset);
1194
1195        let chunks: Vec<_> = content.chunks(100).collect();
1196        assert_eq!(chunks.len(), 1);
1197        assert!(chunks[0].is_ok());
1198    }
1199
1200    #[test]
1201    fn test_raw_plugin_parse() {
1202        let plugin = RawPlugin::new();
1203        let data = b"line1\nline2\nline3";
1204        let content = plugin.parse(data).unwrap();
1205        let batch = content.to_arrow().unwrap();
1206        assert!(batch.num_rows() > 0);
1207    }
1208
1209    #[test]
1210    fn test_raw_plugin_version_and_name() {
1211        let plugin = RawPlugin::new();
1212        assert_eq!(plugin.version(), "1.0.0");
1213        assert_eq!(plugin.name(), "Raw Data Plugin");
1214        assert!(!plugin.description().is_empty());
1215    }
1216
1217    #[test]
1218    fn test_raw_plugin_render_hints() {
1219        let plugin = RawPlugin::new();
1220        let hints = plugin.render_hints();
1221        assert_eq!(hints.chart_type, Some("table".to_string()));
1222    }
1223
1224    #[test]
1225    fn test_course_plugin_parse() {
1226        let plugin = CoursePlugin::new();
1227        let json = r#"{"id":"test","title":"Test","description":"Desc","weeks":4}"#;
1228        let content = plugin.parse(json.as_bytes()).unwrap();
1229        let batch = content.to_arrow().unwrap();
1230        assert_eq!(batch.num_rows(), 1);
1231    }
1232
1233    #[test]
1234    fn test_course_plugin_version_and_name() {
1235        let plugin = CoursePlugin::new();
1236        assert_eq!(plugin.version(), "1.0.0");
1237        assert_eq!(plugin.name(), "Course Plugin");
1238        assert!(!plugin.description().is_empty());
1239    }
1240
1241    #[test]
1242    fn test_course_plugin_render_hints() {
1243        let plugin = CoursePlugin::new();
1244        let hints = plugin.render_hints();
1245        assert_eq!(hints.chart_type, Some("course".to_string()));
1246        assert!(hints.options.contains_key("show_progress"));
1247    }
1248
1249    #[test]
1250    fn test_course_content_metadata() {
1251        let course = CourseContent {
1252            id: "test".to_string(),
1253            title: "Test".to_string(),
1254            description: "Description".to_string(),
1255            short_description: "Short".to_string(),
1256            categories: vec!["cat1".to_string()],
1257            weeks: 3,
1258            featured: true,
1259            difficulty: Some("intermediate".to_string()),
1260            prerequisites: vec!["prereq1".to_string()],
1261            outline: None,
1262        };
1263
1264        let meta = course.metadata();
1265        assert_eq!(meta.content_type, ContentTypeId::course());
1266        assert!(meta.custom.contains_key("weeks"));
1267    }
1268
1269    #[test]
1270    fn test_course_content_chunks() {
1271        let course = CourseContent {
1272            id: "test".to_string(),
1273            title: "Test".to_string(),
1274            description: "Desc".to_string(),
1275            short_description: "Short".to_string(),
1276            categories: vec![],
1277            weeks: 1,
1278            featured: false,
1279            difficulty: None,
1280            prerequisites: vec![],
1281            outline: None,
1282        };
1283
1284        let chunks: Vec<_> = course.chunks(100).collect();
1285        assert_eq!(chunks.len(), 1);
1286    }
1287
1288    #[test]
1289    fn test_course_content_to_bytes() {
1290        let course = CourseContent {
1291            id: "test".to_string(),
1292            title: "Test".to_string(),
1293            description: "Desc".to_string(),
1294            short_description: "".to_string(),
1295            categories: vec![],
1296            weeks: 1,
1297            featured: false,
1298            difficulty: None,
1299            prerequisites: vec![],
1300            outline: None,
1301        };
1302
1303        let bytes = course.to_bytes().unwrap();
1304        assert!(!bytes.is_empty());
1305        let parsed: CourseContent = serde_json::from_slice(&bytes).unwrap();
1306        assert_eq!(parsed.id, "test");
1307    }
1308
1309    #[test]
1310    fn test_course_content_schema() {
1311        let course = CourseContent {
1312            id: "test".to_string(),
1313            title: "Test".to_string(),
1314            description: "Desc".to_string(),
1315            short_description: "".to_string(),
1316            categories: vec![],
1317            weeks: 1,
1318            featured: false,
1319            difficulty: None,
1320            prerequisites: vec![],
1321            outline: None,
1322        };
1323
1324        let schema = course.schema();
1325        assert_eq!(schema.content_type, ContentTypeId::course());
1326    }
1327
1328    #[test]
1329    fn test_plugin_registry_get() {
1330        let registry = PluginRegistry::new();
1331        let plugin = registry.get(&ContentTypeId::dataset()).unwrap();
1332        assert_eq!(plugin.name(), "Dataset Plugin");
1333    }
1334
1335    #[test]
1336    fn test_plugin_registry_content_types() {
1337        let registry = PluginRegistry::new();
1338        let types = registry.content_types();
1339        assert!(types.contains(&ContentTypeId::dataset()));
1340        assert!(types.contains(&ContentTypeId::raw()));
1341    }
1342
1343    #[test]
1344    fn test_plugin_registry_is_empty() {
1345        let registry = PluginRegistry::new();
1346        assert!(!registry.is_empty());
1347    }
1348
1349    // Book Plugin Tests
1350    #[test]
1351    fn test_book_plugin_schema() {
1352        let plugin = BookPlugin::new();
1353        let schema = plugin.schema();
1354        assert_eq!(schema.content_type, ContentTypeId::new("assetgen.book"));
1355        assert!(schema.is_required("id"));
1356        assert!(schema.is_required("title"));
1357    }
1358
1359    #[test]
1360    fn test_book_plugin_version_and_name() {
1361        let plugin = BookPlugin::new();
1362        assert_eq!(plugin.version(), "1.0.0");
1363        assert_eq!(plugin.name(), "Book Plugin");
1364        assert!(!plugin.description().is_empty());
1365    }
1366
1367    #[test]
1368    fn test_book_plugin_render_hints() {
1369        let plugin = BookPlugin::new();
1370        let hints = plugin.render_hints();
1371        assert_eq!(hints.chart_type, Some("book".to_string()));
1372        assert!(hints.options.contains_key("show_progress"));
1373        assert!(hints.options.contains_key("enable_bookmarks"));
1374    }
1375
1376    #[test]
1377    fn test_book_plugin_parse() {
1378        let plugin = BookPlugin::new();
1379        let json = r#"{"id":"test-book","title":"Test Book","author":"Author"}"#;
1380        let content = plugin.parse(json.as_bytes()).unwrap();
1381        let batch = content.to_arrow().unwrap();
1382        // Empty book has no chapters, returns empty batch
1383        assert_eq!(batch.num_rows(), 0);
1384    }
1385
1386    #[test]
1387    fn test_book_content_validation_valid() {
1388        let book = BookContent {
1389            id: "test-book".to_string(),
1390            title: "Test Book".to_string(),
1391            author: "Test Author".to_string(),
1392            description: "A test book".to_string(),
1393            version: "1.0".to_string(),
1394            source_url: None,
1395            settings: None,
1396            chapters: vec![Chapter {
1397                id: "ch1".to_string(),
1398                title: "Chapter 1".to_string(),
1399                order: 1,
1400                source_file: None,
1401                components: vec![],
1402                settings: None,
1403            }],
1404        };
1405
1406        let report = book.validate().unwrap();
1407        assert!(report.valid);
1408    }
1409
1410    #[test]
1411    fn test_book_content_validation_invalid() {
1412        let book = BookContent {
1413            id: "".to_string(),
1414            title: "".to_string(),
1415            author: "".to_string(),
1416            description: "".to_string(),
1417            version: "".to_string(),
1418            source_url: None,
1419            settings: None,
1420            chapters: vec![],
1421        };
1422
1423        let report = book.validate().unwrap();
1424        assert!(!report.valid);
1425        assert!(!report.errors.is_empty());
1426    }
1427
1428    #[test]
1429    fn test_book_content_to_arrow() {
1430        let book = BookContent {
1431            id: "test-book".to_string(),
1432            title: "Test Book".to_string(),
1433            author: "Author".to_string(),
1434            description: "Desc".to_string(),
1435            version: "1.0".to_string(),
1436            source_url: None,
1437            settings: None,
1438            chapters: vec![
1439                Chapter {
1440                    id: "ch1".to_string(),
1441                    title: "Introduction".to_string(),
1442                    order: 1,
1443                    source_file: Some("01-intro.md".to_string()),
1444                    components: vec![],
1445                    settings: Some(ChapterSettings {
1446                        estimated_time: Some("15 minutes".to_string()),
1447                        difficulty: Some("beginner".to_string()),
1448                        prerequisites: vec![],
1449                    }),
1450                },
1451                Chapter {
1452                    id: "ch2".to_string(),
1453                    title: "Getting Started".to_string(),
1454                    order: 2,
1455                    source_file: None,
1456                    components: vec![],
1457                    settings: None,
1458                },
1459            ],
1460        };
1461
1462        let batch = book.to_arrow().unwrap();
1463        assert_eq!(batch.num_rows(), 2);
1464        assert_eq!(batch.num_columns(), 5);
1465    }
1466
1467    #[test]
1468    fn test_book_content_metadata() {
1469        let book = BookContent {
1470            id: "minimal-python".to_string(),
1471            title: "Minimal Python".to_string(),
1472            author: "Noah Gift".to_string(),
1473            description: "A minimal Python book".to_string(),
1474            version: "1.0.0".to_string(),
1475            source_url: Some("https://example.com".to_string()),
1476            settings: None,
1477            chapters: vec![Chapter {
1478                id: "ch1".to_string(),
1479                title: "Ch1".to_string(),
1480                order: 1,
1481                source_file: None,
1482                components: vec![],
1483                settings: None,
1484            }],
1485        };
1486
1487        let meta = book.metadata();
1488        assert_eq!(meta.content_type, ContentTypeId::new("assetgen.book"));
1489        assert!(meta.custom.contains_key("author"));
1490        assert!(meta.custom.contains_key("chapter_count"));
1491    }
1492
1493    #[test]
1494    fn test_book_content_to_bytes() {
1495        let book = BookContent {
1496            id: "test".to_string(),
1497            title: "Test".to_string(),
1498            author: "Author".to_string(),
1499            description: "".to_string(),
1500            version: "".to_string(),
1501            source_url: None,
1502            settings: None,
1503            chapters: vec![],
1504        };
1505
1506        let bytes = book.to_bytes().unwrap();
1507        assert!(!bytes.is_empty());
1508        let parsed: BookContent = serde_json::from_slice(&bytes).unwrap();
1509        assert_eq!(parsed.id, "test");
1510    }
1511
1512    #[test]
1513    fn test_chapter_with_components() {
1514        let chapter = Chapter {
1515            id: "intro".to_string(),
1516            title: "Introduction".to_string(),
1517            order: 1,
1518            source_file: Some("content/01-intro.md".to_string()),
1519            components: vec![
1520                ChapterComponent {
1521                    kind: ComponentType::Terminal,
1522                    id: "intro-terminal".to_string(),
1523                    position: Some("after-paragraph-2".to_string()),
1524                    config: Some(serde_json::json!({
1525                        "initial_code": "print('Hello!')",
1526                        "height": "300px"
1527                    })),
1528                },
1529                ChapterComponent {
1530                    kind: ComponentType::Quiz,
1531                    id: "intro-quiz".to_string(),
1532                    position: Some("end-of-chapter".to_string()),
1533                    config: None,
1534                },
1535            ],
1536            settings: Some(ChapterSettings {
1537                estimated_time: Some("15 minutes".to_string()),
1538                difficulty: Some("beginner".to_string()),
1539                prerequisites: vec![],
1540            }),
1541        };
1542
1543        assert_eq!(chapter.components.len(), 2);
1544        assert_eq!(chapter.components[0].kind, ComponentType::Terminal);
1545        assert_eq!(chapter.components[1].kind, ComponentType::Quiz);
1546    }
1547
1548    #[test]
1549    fn test_register_book_plugin() {
1550        let mut registry = PluginRegistry::new();
1551        registry.register(Box::new(BookPlugin::new()));
1552
1553        assert!(registry.has(&ContentTypeId::new("assetgen.book")));
1554        assert_eq!(registry.len(), 3); // dataset, raw, book
1555    }
1556}