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    // `mod plugin` is private, so nothing outside this file can call it yet; the
406    // struct above carries the same allow for the same reason. rustc >= 1.95
407    // reports unreachable associated fns, which the struct-level allow does not
408    // cover.
409    #[allow(dead_code)]
410    pub fn new() -> Self {
411        Self
412    }
413}
414
415impl Default for CoursePlugin {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421impl ContentPlugin for CoursePlugin {
422    fn content_type(&self) -> ContentTypeId {
423        ContentTypeId::course()
424    }
425
426    fn schema(&self) -> ContentSchema {
427        ContentSchema::new(ContentTypeId::course(), "1.0")
428            .with_field(
429                FieldDefinition::new("id", FieldType::String)
430                    .with_description("Unique course identifier")
431                    .with_constraint(Constraint::pattern(r"^[a-z0-9-]+$"))
432                    .with_constraint(Constraint::max_length(64)),
433            )
434            .with_field(
435                FieldDefinition::new("title", FieldType::String)
436                    .with_description("Course title")
437                    .with_constraint(Constraint::min_length(1))
438                    .with_constraint(Constraint::max_length(256)),
439            )
440            .with_field(
441                FieldDefinition::new("description", FieldType::String)
442                    .with_description("Full course description"),
443            )
444            .with_field(
445                FieldDefinition::new("short_description", FieldType::String)
446                    .with_description("Brief course summary")
447                    .with_constraint(Constraint::max_length(500)),
448            )
449            .with_field(
450                FieldDefinition::new("categories", FieldType::array(FieldType::String))
451                    .with_description("Course categories"),
452            )
453            .with_field(
454                FieldDefinition::new("weeks", FieldType::Integer)
455                    .with_description("Number of weeks")
456                    .with_constraint(Constraint::min(1.0))
457                    .with_constraint(Constraint::max(52.0)),
458            )
459            .with_field(
460                FieldDefinition::new("featured", FieldType::Boolean)
461                    .with_description("Whether course is featured")
462                    .with_default(serde_json::json!(false)),
463            )
464            .with_required("id")
465            .with_required("title")
466            .with_required("description")
467            .with_required("weeks")
468    }
469
470    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
471        let text =
472            std::str::from_utf8(data).map_err(|e| Error::parse(format!("Invalid UTF-8: {e}")))?;
473
474        // Parse as JSON (course outline format)
475        let course: CourseContent = serde_json::from_str(text)
476            .map_err(|e| Error::parse(format!("Invalid course JSON: {e}")))?;
477
478        Ok(Box::new(course))
479    }
480
481    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
482        content.to_bytes()
483    }
484
485    fn render_hints(&self) -> RenderHints {
486        RenderHints::new()
487            .with_chart_type("course")
488            .with_option("show_progress", serde_json::json!(true))
489    }
490
491    fn version(&self) -> &'static str {
492        "1.0.0"
493    }
494
495    fn name(&self) -> &'static str {
496        "Course Plugin"
497    }
498
499    fn description(&self) -> &'static str {
500        "Handles assetgen course content"
501    }
502}
503
504/// Course content structure (aligned with assetgen)
505#[allow(dead_code)]
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CourseContent {
508    /// Course ID
509    pub id: String,
510    /// Course title
511    pub title: String,
512    /// Full description
513    pub description: String,
514    /// Short description
515    #[serde(default)]
516    pub short_description: String,
517    /// Categories
518    #[serde(default)]
519    pub categories: Vec<String>,
520    /// Number of weeks
521    pub weeks: u32,
522    /// Featured flag
523    #[serde(default)]
524    pub featured: bool,
525    /// Difficulty level
526    #[serde(default)]
527    pub difficulty: Option<String>,
528    /// Prerequisites
529    #[serde(default)]
530    pub prerequisites: Vec<String>,
531    /// Course outline
532    #[serde(default)]
533    pub outline: Option<CourseOutline>,
534}
535
536/// Course outline structure
537#[allow(dead_code)]
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct CourseOutline {
540    /// Outline title
541    pub title: String,
542    /// Weeks in the course
543    #[serde(default)]
544    pub weeks: Vec<Week>,
545}
546
547/// Week structure
548#[allow(dead_code)]
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct Week {
551    /// Week number
552    pub number: u32,
553    /// Week title
554    pub title: String,
555    /// Lessons in this week
556    #[serde(default)]
557    pub lessons: Vec<Lesson>,
558}
559
560/// Lesson structure
561#[allow(dead_code)]
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct Lesson {
564    /// Lesson number (e.g., "1.1", "1.2")
565    pub number: String,
566    /// Lesson title
567    pub title: String,
568    /// Lesson assets
569    #[serde(default)]
570    pub assets: Vec<Asset>,
571}
572
573/// Asset structure
574#[allow(dead_code)]
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct Asset {
577    /// Asset filename
578    pub filename: String,
579    /// Asset type
580    #[serde(rename = "type")]
581    pub kind: AssetType,
582    /// Asset description
583    #[serde(default)]
584    pub description: Option<String>,
585}
586
587/// Asset type enumeration
588#[allow(dead_code)]
589#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
590#[serde(rename_all = "lowercase")]
591pub enum AssetType {
592    /// Video asset
593    Video,
594    /// Key terms asset
595    KeyTerms,
596    /// Quiz asset
597    Quiz,
598    /// Lab asset
599    Lab,
600    /// Reflection asset
601    Reflection,
602}
603
604impl ServeableContent for CourseContent {
605    fn schema(&self) -> ContentSchema {
606        CoursePlugin::new().schema()
607    }
608
609    fn validate(&self) -> Result<ValidationReport> {
610        let mut report = ValidationReport::success();
611
612        if self.id.is_empty() {
613            report = report.with_error(crate::serve::content::ValidationError::new(
614                "id",
615                "Course ID is required",
616            ));
617        }
618
619        if self.title.is_empty() {
620            report = report.with_error(crate::serve::content::ValidationError::new(
621                "title",
622                "Course title is required",
623            ));
624        }
625
626        if self.weeks == 0 {
627            report = report.with_error(crate::serve::content::ValidationError::new(
628                "weeks",
629                "Course must have at least 1 week",
630            ));
631        }
632
633        Ok(report)
634    }
635
636    fn to_arrow(&self) -> Result<RecordBatch> {
637        // Convert course to a simple table representation
638        let schema = Arc::new(Schema::new(vec![
639            Field::new("id", DataType::Utf8, false),
640            Field::new("title", DataType::Utf8, false),
641            Field::new("description", DataType::Utf8, false),
642            Field::new("weeks", DataType::Utf8, false),
643            Field::new("categories", DataType::Utf8, false),
644        ]));
645
646        let id_array: ArrayRef = Arc::new(StringArray::from(vec![self.id.as_str()]));
647        let title_array: ArrayRef = Arc::new(StringArray::from(vec![self.title.as_str()]));
648        let desc_array: ArrayRef = Arc::new(StringArray::from(vec![self.description.as_str()]));
649        let weeks_array: ArrayRef = Arc::new(StringArray::from(vec![self.weeks.to_string()]));
650        let cats_array: ArrayRef = Arc::new(StringArray::from(vec![self.categories.join(", ")]));
651
652        RecordBatch::try_new(
653            schema,
654            vec![id_array, title_array, desc_array, weeks_array, cats_array],
655        )
656        .map_err(|e| Error::data(format!("Failed to create course batch: {e}")))
657    }
658
659    fn metadata(&self) -> ContentMetadata {
660        ContentMetadata::new(ContentTypeId::course(), &self.title, 0)
661            .with_description(&self.description)
662            .with_custom("id", serde_json::json!(&self.id))
663            .with_custom("weeks", serde_json::json!(self.weeks))
664            .with_custom("categories", serde_json::json!(&self.categories))
665    }
666
667    fn content_type(&self) -> ContentTypeId {
668        ContentTypeId::course()
669    }
670
671    fn chunks(&self, _chunk_size: usize) -> Box<dyn Iterator<Item = Result<RecordBatch>> + Send> {
672        let batch_result = self.to_arrow();
673        Box::new(std::iter::once(batch_result))
674    }
675
676    fn to_bytes(&self) -> Result<Vec<u8>> {
677        serde_json::to_vec(self)
678            .map_err(|e| Error::data(format!("Failed to serialize course: {e}")))
679    }
680}
681
682// ============================================================================
683// Book Plugin (assetgen book/chapter integration)
684// ============================================================================
685
686/// Book plugin for assetgen book content with chapters
687#[allow(dead_code)]
688pub struct BookPlugin;
689
690impl BookPlugin {
691    /// Create a new book plugin
692    // See `CoursePlugin::new` — private module, so this is unreachable until
693    // `mod plugin` is exported.
694    #[allow(dead_code)]
695    pub fn new() -> Self {
696        Self
697    }
698}
699
700impl Default for BookPlugin {
701    fn default() -> Self {
702        Self::new()
703    }
704}
705
706impl ContentPlugin for BookPlugin {
707    fn content_type(&self) -> ContentTypeId {
708        ContentTypeId::new("assetgen.book")
709    }
710
711    fn schema(&self) -> ContentSchema {
712        ContentSchema::new(ContentTypeId::new("assetgen.book"), "1.0")
713            .with_field(
714                FieldDefinition::new("id", FieldType::String).with_description("Book identifier"),
715            )
716            .with_field(
717                FieldDefinition::new("title", FieldType::String).with_description("Book title"),
718            )
719            .with_field(
720                FieldDefinition::new("author", FieldType::String).with_description("Book author"),
721            )
722            .with_field(
723                FieldDefinition::new("description", FieldType::String)
724                    .with_description("Book description"),
725            )
726            .with_field(
727                FieldDefinition::new("version", FieldType::String).with_description("Book version"),
728            )
729            .with_field(
730                FieldDefinition::new(
731                    "chapters",
732                    FieldType::array(FieldType::Object {
733                        schema: Box::new(ContentSchema::new(
734                            ContentTypeId::new("assetgen.chapter"),
735                            "1.0",
736                        )),
737                    }),
738                )
739                .with_description("Book chapters"),
740            )
741            .with_required("id")
742            .with_required("title")
743    }
744
745    fn parse(&self, data: &[u8]) -> Result<BoxedContent> {
746        let text =
747            std::str::from_utf8(data).map_err(|e| Error::parse(format!("Invalid UTF-8: {e}")))?;
748
749        let book: BookContent = serde_json::from_str(text)
750            .map_err(|e| Error::parse(format!("Invalid book JSON: {e}")))?;
751
752        Ok(Box::new(book))
753    }
754
755    fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>> {
756        content.to_bytes()
757    }
758
759    fn render_hints(&self) -> RenderHints {
760        RenderHints::new()
761            .with_chart_type("book")
762            .with_option("show_progress", serde_json::json!(true))
763            .with_option("enable_bookmarks", serde_json::json!(true))
764    }
765
766    fn version(&self) -> &'static str {
767        "1.0.0"
768    }
769
770    fn name(&self) -> &'static str {
771        "Book Plugin"
772    }
773
774    fn description(&self) -> &'static str {
775        "Handles assetgen book content with chapters"
776    }
777}
778
779/// Book content structure (aligned with assetgen books)
780#[allow(dead_code)]
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct BookContent {
783    /// Book ID
784    pub id: String,
785    /// Book title
786    pub title: String,
787    /// Book author
788    pub author: String,
789    /// Book description
790    #[serde(default)]
791    pub description: String,
792    /// Book version
793    #[serde(default)]
794    pub version: String,
795    /// Source URL
796    #[serde(default)]
797    pub source_url: Option<String>,
798    /// Book settings
799    #[serde(default)]
800    pub settings: Option<BookSettings>,
801    /// Book chapters
802    #[serde(default)]
803    pub chapters: Vec<Chapter>,
804}
805
806/// Book feature enumeration
807#[allow(dead_code)]
808#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
809#[serde(rename_all = "lowercase")]
810pub enum BookFeature {
811    /// Enable terminal component
812    Terminal,
813    /// Enable quizzes
814    Quizzes,
815    /// Enable labs
816    Labs,
817    /// Show progress indicator
818    Progress,
819    /// Enable bookmarks
820    Bookmarks,
821}
822
823/// Book settings
824#[allow(dead_code)]
825#[derive(Debug, Clone, Serialize, Deserialize, Default)]
826pub struct BookSettings {
827    /// Enabled features (set of feature flags)
828    #[serde(default)]
829    pub features: std::collections::HashSet<BookFeature>,
830    /// Default Python version
831    #[serde(default)]
832    pub default_python_version: Option<String>,
833    /// Required packages
834    #[serde(default)]
835    pub required_packages: Vec<String>,
836    /// Navigation type (sequential, free)
837    #[serde(default)]
838    pub navigation_type: Option<String>,
839}
840
841/// Chapter structure
842#[allow(dead_code)]
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct Chapter {
845    /// Chapter ID
846    pub id: String,
847    /// Chapter title
848    pub title: String,
849    /// Chapter order
850    #[serde(default)]
851    pub order: u32,
852    /// Source file path
853    #[serde(default)]
854    pub source_file: Option<String>,
855    /// Interactive components
856    #[serde(default)]
857    pub components: Vec<ChapterComponent>,
858    /// Chapter settings
859    #[serde(default)]
860    pub settings: Option<ChapterSettings>,
861}
862
863/// Chapter settings
864#[allow(dead_code)]
865#[derive(Debug, Clone, Serialize, Deserialize, Default)]
866pub struct ChapterSettings {
867    /// Estimated reading time
868    #[serde(default)]
869    pub estimated_time: Option<String>,
870    /// Difficulty level
871    #[serde(default)]
872    pub difficulty: Option<String>,
873    /// Prerequisites (other chapter IDs)
874    #[serde(default)]
875    pub prerequisites: Vec<String>,
876}
877
878/// Interactive chapter component
879#[allow(dead_code)]
880#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct ChapterComponent {
882    /// Component type (terminal, quiz, lab)
883    #[serde(rename = "type")]
884    pub kind: ComponentType,
885    /// Component ID
886    pub id: String,
887    /// Position in chapter
888    #[serde(default)]
889    pub position: Option<String>,
890    /// Component configuration
891    #[serde(default)]
892    pub config: Option<serde_json::Value>,
893}
894
895/// Component type enumeration
896#[allow(dead_code)]
897#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
898#[serde(rename_all = "lowercase")]
899pub enum ComponentType {
900    /// Interactive terminal
901    Terminal,
902    /// Quiz component
903    Quiz,
904    /// Lab exercise
905    Lab,
906    /// Code editor
907    Editor,
908    /// Visualization
909    Visualization,
910}
911
912impl ServeableContent for BookContent {
913    fn schema(&self) -> ContentSchema {
914        BookPlugin::new().schema()
915    }
916
917    fn validate(&self) -> Result<ValidationReport> {
918        let mut report = ValidationReport::success();
919
920        if self.id.is_empty() {
921            report = report.with_error(crate::serve::content::ValidationError::new(
922                "id",
923                "Book ID is required",
924            ));
925        }
926
927        if self.title.is_empty() {
928            report = report.with_error(crate::serve::content::ValidationError::new(
929                "title",
930                "Book title is required",
931            ));
932        }
933
934        if self.author.is_empty() {
935            report = report.with_error(crate::serve::content::ValidationError::new(
936                "author",
937                "Book author is required",
938            ));
939        }
940
941        // Validate chapters
942        for (i, chapter) in self.chapters.iter().enumerate() {
943            if chapter.id.is_empty() {
944                report = report.with_error(crate::serve::content::ValidationError::new(
945                    format!("chapters[{}].id", i),
946                    "Chapter ID is required",
947                ));
948            }
949            if chapter.title.is_empty() {
950                report = report.with_error(crate::serve::content::ValidationError::new(
951                    format!("chapters[{}].title", i),
952                    "Chapter title is required",
953                ));
954            }
955        }
956
957        Ok(report)
958    }
959
960    fn to_arrow(&self) -> Result<RecordBatch> {
961        // Convert book chapters to a table representation
962        let schema = Arc::new(Schema::new(vec![
963            Field::new("chapter_id", DataType::Utf8, false),
964            Field::new("chapter_title", DataType::Utf8, false),
965            Field::new("order", DataType::Utf8, false),
966            Field::new("difficulty", DataType::Utf8, true),
967            Field::new("estimated_time", DataType::Utf8, true),
968        ]));
969
970        if self.chapters.is_empty() {
971            // Return empty batch with schema
972            return Ok(RecordBatch::new_empty(schema));
973        }
974
975        let chapter_ids: Vec<&str> = self.chapters.iter().map(|c| c.id.as_str()).collect();
976        let chapter_titles: Vec<&str> = self.chapters.iter().map(|c| c.title.as_str()).collect();
977        let orders: Vec<String> = self.chapters.iter().map(|c| c.order.to_string()).collect();
978        let difficulties: Vec<Option<&str>> = self
979            .chapters
980            .iter()
981            .map(|c| c.settings.as_ref().and_then(|s| s.difficulty.as_deref()))
982            .collect();
983        let times: Vec<Option<&str>> = self
984            .chapters
985            .iter()
986            .map(|c| {
987                c.settings
988                    .as_ref()
989                    .and_then(|s| s.estimated_time.as_deref())
990            })
991            .collect();
992
993        let id_array: ArrayRef = Arc::new(StringArray::from(chapter_ids));
994        let title_array: ArrayRef = Arc::new(StringArray::from(chapter_titles));
995        let order_array: ArrayRef = Arc::new(StringArray::from(orders));
996        let diff_array: ArrayRef = Arc::new(StringArray::from(difficulties));
997        let time_array: ArrayRef = Arc::new(StringArray::from(times));
998
999        RecordBatch::try_new(
1000            schema,
1001            vec![id_array, title_array, order_array, diff_array, time_array],
1002        )
1003        .map_err(|e| Error::data(format!("Failed to create book batch: {e}")))
1004    }
1005
1006    fn metadata(&self) -> ContentMetadata {
1007        ContentMetadata::new(ContentTypeId::new("assetgen.book"), &self.title, 0)
1008            .with_description(&self.description)
1009            .with_custom("id", serde_json::json!(&self.id))
1010            .with_custom("author", serde_json::json!(&self.author))
1011            .with_custom("version", serde_json::json!(&self.version))
1012            .with_custom("chapter_count", serde_json::json!(self.chapters.len()))
1013    }
1014
1015    fn content_type(&self) -> ContentTypeId {
1016        ContentTypeId::new("assetgen.book")
1017    }
1018
1019    fn chunks(&self, _chunk_size: usize) -> Box<dyn Iterator<Item = Result<RecordBatch>> + Send> {
1020        let batch_result = self.to_arrow();
1021        Box::new(std::iter::once(batch_result))
1022    }
1023
1024    fn to_bytes(&self) -> Result<Vec<u8>> {
1025        serde_json::to_vec(self).map_err(|e| Error::data(format!("Failed to serialize book: {e}")))
1026    }
1027}
1028
1029#[cfg(test)]
1030#[allow(clippy::unwrap_used, clippy::manual_string_new)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn test_plugin_registry() {
1036        let registry = PluginRegistry::new();
1037
1038        assert!(registry.has(&ContentTypeId::dataset()));
1039        assert!(registry.has(&ContentTypeId::raw()));
1040        assert!(!registry.has(&ContentTypeId::course()));
1041    }
1042
1043    #[test]
1044    fn test_register_course_plugin() {
1045        let mut registry = PluginRegistry::new();
1046        registry.register(Box::new(CoursePlugin::new()));
1047
1048        assert!(registry.has(&ContentTypeId::course()));
1049        assert_eq!(registry.len(), 3);
1050    }
1051
1052    #[test]
1053    fn test_dataset_plugin_schema() {
1054        let plugin = DatasetPlugin::new();
1055        let schema = plugin.schema();
1056
1057        assert_eq!(schema.content_type, ContentTypeId::dataset());
1058        assert!(schema.is_required("name"));
1059    }
1060
1061    #[test]
1062    fn test_course_plugin_schema() {
1063        let plugin = CoursePlugin::new();
1064        let schema = plugin.schema();
1065
1066        assert_eq!(schema.content_type, ContentTypeId::course());
1067        assert!(schema.is_required("id"));
1068        assert!(schema.is_required("title"));
1069    }
1070
1071    #[test]
1072    fn test_render_hints() {
1073        let hints = RenderHints::new()
1074            .with_chart_type("scatter")
1075            .with_x_column("x")
1076            .with_y_column("y")
1077            .with_option("point_size", serde_json::json!(5));
1078
1079        assert_eq!(hints.chart_type, Some("scatter".to_string()));
1080        assert_eq!(hints.x_column, Some("x".to_string()));
1081        assert!(hints.options.contains_key("point_size"));
1082    }
1083
1084    #[test]
1085    fn test_course_content_validation() {
1086        let valid_course = CourseContent {
1087            id: "rust-fundamentals".to_string(),
1088            title: "Rust Fundamentals".to_string(),
1089            description: "Learn Rust programming".to_string(),
1090            short_description: "Learn Rust".to_string(),
1091            categories: vec!["programming".to_string()],
1092            weeks: 4,
1093            featured: false,
1094            difficulty: Some("beginner".to_string()),
1095            prerequisites: vec![],
1096            outline: None,
1097        };
1098
1099        let report = valid_course.validate().unwrap();
1100        assert!(report.valid);
1101
1102        let invalid_course = CourseContent {
1103            id: "".to_string(),
1104            title: "".to_string(),
1105            description: "".to_string(),
1106            short_description: "".to_string(),
1107            categories: vec![],
1108            weeks: 0,
1109            featured: false,
1110            difficulty: None,
1111            prerequisites: vec![],
1112            outline: None,
1113        };
1114
1115        let report = invalid_course.validate().unwrap();
1116        assert!(!report.valid);
1117        assert!(!report.errors.is_empty());
1118    }
1119
1120    #[test]
1121    fn test_course_to_arrow() {
1122        let course = CourseContent {
1123            id: "test-course".to_string(),
1124            title: "Test Course".to_string(),
1125            description: "A test course".to_string(),
1126            short_description: "Test".to_string(),
1127            categories: vec!["test".to_string()],
1128            weeks: 2,
1129            featured: false,
1130            difficulty: None,
1131            prerequisites: vec![],
1132            outline: None,
1133        };
1134
1135        let batch = course.to_arrow().unwrap();
1136        assert_eq!(batch.num_rows(), 1);
1137        assert_eq!(batch.num_columns(), 5);
1138    }
1139
1140    #[test]
1141    fn test_dataset_plugin_parse_csv() {
1142        let plugin = DatasetPlugin::new();
1143        let csv_data = b"name,age\nAlice,30\nBob,25";
1144        let content = plugin.parse(csv_data).unwrap();
1145        let batch = content.to_arrow().unwrap();
1146        // CSV with header has 2 data rows
1147        assert!(batch.num_rows() >= 1);
1148        assert!(batch.num_columns() >= 1);
1149    }
1150
1151    #[test]
1152    fn test_dataset_plugin_parse_json() {
1153        let plugin = DatasetPlugin::new();
1154        // Arrow JSON reader expects newline-delimited JSON objects
1155        let json_data = b"{\"name\":\"Alice\",\"age\":30}\n{\"name\":\"Bob\",\"age\":25}";
1156        let content = plugin.parse(json_data).unwrap();
1157        let batch = content.to_arrow().unwrap();
1158        assert!(batch.num_rows() >= 1);
1159    }
1160
1161    #[test]
1162    fn test_dataset_plugin_serialize() {
1163        use crate::ArrowDataset;
1164
1165        let csv_data = "name,value\na,1\nb,2";
1166        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1167        let content = DatasetContent::new(dataset);
1168
1169        let plugin = DatasetPlugin::new();
1170        let bytes = plugin.serialize(&content).unwrap();
1171        assert!(!bytes.is_empty());
1172    }
1173
1174    #[test]
1175    fn test_dataset_plugin_version_and_name() {
1176        let plugin = DatasetPlugin::new();
1177        assert_eq!(plugin.version(), "1.0.0");
1178        assert_eq!(plugin.name(), "Dataset Plugin");
1179        assert!(!plugin.description().is_empty());
1180    }
1181
1182    #[test]
1183    fn test_dataset_content_metadata() {
1184        use crate::ArrowDataset;
1185
1186        let csv_data = "name,value\na,1\nb,2";
1187        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1188        let content = DatasetContent::new(dataset);
1189
1190        let meta = content.metadata();
1191        assert_eq!(meta.content_type, ContentTypeId::dataset());
1192        assert_eq!(meta.row_count, Some(2));
1193    }
1194
1195    #[test]
1196    fn test_dataset_content_chunks() {
1197        use crate::ArrowDataset;
1198
1199        let csv_data = "name,value\na,1\nb,2";
1200        let dataset = ArrowDataset::from_csv_str(csv_data).unwrap();
1201        let content = DatasetContent::new(dataset);
1202
1203        let chunks: Vec<_> = content.chunks(100).collect();
1204        assert_eq!(chunks.len(), 1);
1205        assert!(chunks[0].is_ok());
1206    }
1207
1208    #[test]
1209    fn test_raw_plugin_parse() {
1210        let plugin = RawPlugin::new();
1211        let data = b"line1\nline2\nline3";
1212        let content = plugin.parse(data).unwrap();
1213        let batch = content.to_arrow().unwrap();
1214        assert!(batch.num_rows() > 0);
1215    }
1216
1217    #[test]
1218    fn test_raw_plugin_version_and_name() {
1219        let plugin = RawPlugin::new();
1220        assert_eq!(plugin.version(), "1.0.0");
1221        assert_eq!(plugin.name(), "Raw Data Plugin");
1222        assert!(!plugin.description().is_empty());
1223    }
1224
1225    #[test]
1226    fn test_raw_plugin_render_hints() {
1227        let plugin = RawPlugin::new();
1228        let hints = plugin.render_hints();
1229        assert_eq!(hints.chart_type, Some("table".to_string()));
1230    }
1231
1232    #[test]
1233    fn test_course_plugin_parse() {
1234        let plugin = CoursePlugin::new();
1235        let json = r#"{"id":"test","title":"Test","description":"Desc","weeks":4}"#;
1236        let content = plugin.parse(json.as_bytes()).unwrap();
1237        let batch = content.to_arrow().unwrap();
1238        assert_eq!(batch.num_rows(), 1);
1239    }
1240
1241    #[test]
1242    fn test_course_plugin_version_and_name() {
1243        let plugin = CoursePlugin::new();
1244        assert_eq!(plugin.version(), "1.0.0");
1245        assert_eq!(plugin.name(), "Course Plugin");
1246        assert!(!plugin.description().is_empty());
1247    }
1248
1249    #[test]
1250    fn test_course_plugin_render_hints() {
1251        let plugin = CoursePlugin::new();
1252        let hints = plugin.render_hints();
1253        assert_eq!(hints.chart_type, Some("course".to_string()));
1254        assert!(hints.options.contains_key("show_progress"));
1255    }
1256
1257    #[test]
1258    fn test_course_content_metadata() {
1259        let course = CourseContent {
1260            id: "test".to_string(),
1261            title: "Test".to_string(),
1262            description: "Description".to_string(),
1263            short_description: "Short".to_string(),
1264            categories: vec!["cat1".to_string()],
1265            weeks: 3,
1266            featured: true,
1267            difficulty: Some("intermediate".to_string()),
1268            prerequisites: vec!["prereq1".to_string()],
1269            outline: None,
1270        };
1271
1272        let meta = course.metadata();
1273        assert_eq!(meta.content_type, ContentTypeId::course());
1274        assert!(meta.custom.contains_key("weeks"));
1275    }
1276
1277    #[test]
1278    fn test_course_content_chunks() {
1279        let course = CourseContent {
1280            id: "test".to_string(),
1281            title: "Test".to_string(),
1282            description: "Desc".to_string(),
1283            short_description: "Short".to_string(),
1284            categories: vec![],
1285            weeks: 1,
1286            featured: false,
1287            difficulty: None,
1288            prerequisites: vec![],
1289            outline: None,
1290        };
1291
1292        let chunks: Vec<_> = course.chunks(100).collect();
1293        assert_eq!(chunks.len(), 1);
1294    }
1295
1296    #[test]
1297    fn test_course_content_to_bytes() {
1298        let course = CourseContent {
1299            id: "test".to_string(),
1300            title: "Test".to_string(),
1301            description: "Desc".to_string(),
1302            short_description: "".to_string(),
1303            categories: vec![],
1304            weeks: 1,
1305            featured: false,
1306            difficulty: None,
1307            prerequisites: vec![],
1308            outline: None,
1309        };
1310
1311        let bytes = course.to_bytes().unwrap();
1312        assert!(!bytes.is_empty());
1313        let parsed: CourseContent = serde_json::from_slice(&bytes).unwrap();
1314        assert_eq!(parsed.id, "test");
1315    }
1316
1317    #[test]
1318    fn test_course_content_schema() {
1319        let course = CourseContent {
1320            id: "test".to_string(),
1321            title: "Test".to_string(),
1322            description: "Desc".to_string(),
1323            short_description: "".to_string(),
1324            categories: vec![],
1325            weeks: 1,
1326            featured: false,
1327            difficulty: None,
1328            prerequisites: vec![],
1329            outline: None,
1330        };
1331
1332        let schema = course.schema();
1333        assert_eq!(schema.content_type, ContentTypeId::course());
1334    }
1335
1336    #[test]
1337    fn test_plugin_registry_get() {
1338        let registry = PluginRegistry::new();
1339        let plugin = registry.get(&ContentTypeId::dataset()).unwrap();
1340        assert_eq!(plugin.name(), "Dataset Plugin");
1341    }
1342
1343    #[test]
1344    fn test_plugin_registry_content_types() {
1345        let registry = PluginRegistry::new();
1346        let types = registry.content_types();
1347        assert!(types.contains(&ContentTypeId::dataset()));
1348        assert!(types.contains(&ContentTypeId::raw()));
1349    }
1350
1351    #[test]
1352    fn test_plugin_registry_is_empty() {
1353        let registry = PluginRegistry::new();
1354        assert!(!registry.is_empty());
1355    }
1356
1357    // Book Plugin Tests
1358    #[test]
1359    fn test_book_plugin_schema() {
1360        let plugin = BookPlugin::new();
1361        let schema = plugin.schema();
1362        assert_eq!(schema.content_type, ContentTypeId::new("assetgen.book"));
1363        assert!(schema.is_required("id"));
1364        assert!(schema.is_required("title"));
1365    }
1366
1367    #[test]
1368    fn test_book_plugin_version_and_name() {
1369        let plugin = BookPlugin::new();
1370        assert_eq!(plugin.version(), "1.0.0");
1371        assert_eq!(plugin.name(), "Book Plugin");
1372        assert!(!plugin.description().is_empty());
1373    }
1374
1375    #[test]
1376    fn test_book_plugin_render_hints() {
1377        let plugin = BookPlugin::new();
1378        let hints = plugin.render_hints();
1379        assert_eq!(hints.chart_type, Some("book".to_string()));
1380        assert!(hints.options.contains_key("show_progress"));
1381        assert!(hints.options.contains_key("enable_bookmarks"));
1382    }
1383
1384    #[test]
1385    fn test_book_plugin_parse() {
1386        let plugin = BookPlugin::new();
1387        let json = r#"{"id":"test-book","title":"Test Book","author":"Author"}"#;
1388        let content = plugin.parse(json.as_bytes()).unwrap();
1389        let batch = content.to_arrow().unwrap();
1390        // Empty book has no chapters, returns empty batch
1391        assert_eq!(batch.num_rows(), 0);
1392    }
1393
1394    #[test]
1395    fn test_book_content_validation_valid() {
1396        let book = BookContent {
1397            id: "test-book".to_string(),
1398            title: "Test Book".to_string(),
1399            author: "Test Author".to_string(),
1400            description: "A test book".to_string(),
1401            version: "1.0".to_string(),
1402            source_url: None,
1403            settings: None,
1404            chapters: vec![Chapter {
1405                id: "ch1".to_string(),
1406                title: "Chapter 1".to_string(),
1407                order: 1,
1408                source_file: None,
1409                components: vec![],
1410                settings: None,
1411            }],
1412        };
1413
1414        let report = book.validate().unwrap();
1415        assert!(report.valid);
1416    }
1417
1418    #[test]
1419    fn test_book_content_validation_invalid() {
1420        let book = BookContent {
1421            id: "".to_string(),
1422            title: "".to_string(),
1423            author: "".to_string(),
1424            description: "".to_string(),
1425            version: "".to_string(),
1426            source_url: None,
1427            settings: None,
1428            chapters: vec![],
1429        };
1430
1431        let report = book.validate().unwrap();
1432        assert!(!report.valid);
1433        assert!(!report.errors.is_empty());
1434    }
1435
1436    #[test]
1437    fn test_book_content_to_arrow() {
1438        let book = BookContent {
1439            id: "test-book".to_string(),
1440            title: "Test Book".to_string(),
1441            author: "Author".to_string(),
1442            description: "Desc".to_string(),
1443            version: "1.0".to_string(),
1444            source_url: None,
1445            settings: None,
1446            chapters: vec![
1447                Chapter {
1448                    id: "ch1".to_string(),
1449                    title: "Introduction".to_string(),
1450                    order: 1,
1451                    source_file: Some("01-intro.md".to_string()),
1452                    components: vec![],
1453                    settings: Some(ChapterSettings {
1454                        estimated_time: Some("15 minutes".to_string()),
1455                        difficulty: Some("beginner".to_string()),
1456                        prerequisites: vec![],
1457                    }),
1458                },
1459                Chapter {
1460                    id: "ch2".to_string(),
1461                    title: "Getting Started".to_string(),
1462                    order: 2,
1463                    source_file: None,
1464                    components: vec![],
1465                    settings: None,
1466                },
1467            ],
1468        };
1469
1470        let batch = book.to_arrow().unwrap();
1471        assert_eq!(batch.num_rows(), 2);
1472        assert_eq!(batch.num_columns(), 5);
1473    }
1474
1475    #[test]
1476    fn test_book_content_metadata() {
1477        let book = BookContent {
1478            id: "minimal-python".to_string(),
1479            title: "Minimal Python".to_string(),
1480            author: "Noah Gift".to_string(),
1481            description: "A minimal Python book".to_string(),
1482            version: "1.0.0".to_string(),
1483            source_url: Some("https://example.com".to_string()),
1484            settings: None,
1485            chapters: vec![Chapter {
1486                id: "ch1".to_string(),
1487                title: "Ch1".to_string(),
1488                order: 1,
1489                source_file: None,
1490                components: vec![],
1491                settings: None,
1492            }],
1493        };
1494
1495        let meta = book.metadata();
1496        assert_eq!(meta.content_type, ContentTypeId::new("assetgen.book"));
1497        assert!(meta.custom.contains_key("author"));
1498        assert!(meta.custom.contains_key("chapter_count"));
1499    }
1500
1501    #[test]
1502    fn test_book_content_to_bytes() {
1503        let book = BookContent {
1504            id: "test".to_string(),
1505            title: "Test".to_string(),
1506            author: "Author".to_string(),
1507            description: "".to_string(),
1508            version: "".to_string(),
1509            source_url: None,
1510            settings: None,
1511            chapters: vec![],
1512        };
1513
1514        let bytes = book.to_bytes().unwrap();
1515        assert!(!bytes.is_empty());
1516        let parsed: BookContent = serde_json::from_slice(&bytes).unwrap();
1517        assert_eq!(parsed.id, "test");
1518    }
1519
1520    #[test]
1521    fn test_chapter_with_components() {
1522        let chapter = Chapter {
1523            id: "intro".to_string(),
1524            title: "Introduction".to_string(),
1525            order: 1,
1526            source_file: Some("content/01-intro.md".to_string()),
1527            components: vec![
1528                ChapterComponent {
1529                    kind: ComponentType::Terminal,
1530                    id: "intro-terminal".to_string(),
1531                    position: Some("after-paragraph-2".to_string()),
1532                    config: Some(serde_json::json!({
1533                        "initial_code": "print('Hello!')",
1534                        "height": "300px"
1535                    })),
1536                },
1537                ChapterComponent {
1538                    kind: ComponentType::Quiz,
1539                    id: "intro-quiz".to_string(),
1540                    position: Some("end-of-chapter".to_string()),
1541                    config: None,
1542                },
1543            ],
1544            settings: Some(ChapterSettings {
1545                estimated_time: Some("15 minutes".to_string()),
1546                difficulty: Some("beginner".to_string()),
1547                prerequisites: vec![],
1548            }),
1549        };
1550
1551        assert_eq!(chapter.components.len(), 2);
1552        assert_eq!(chapter.components[0].kind, ComponentType::Terminal);
1553        assert_eq!(chapter.components[1].kind, ComponentType::Quiz);
1554    }
1555
1556    #[test]
1557    fn test_register_book_plugin() {
1558        let mut registry = PluginRegistry::new();
1559        registry.register(Box::new(BookPlugin::new()));
1560
1561        assert!(registry.has(&ContentTypeId::new("assetgen.book")));
1562        assert_eq!(registry.len(), 3); // dataset, raw, book
1563    }
1564}