1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct RenderHints {
28 pub chart_type: Option<String>,
30 pub x_column: Option<String>,
32 pub y_column: Option<String>,
34 pub color_column: Option<String>,
36 #[serde(default)]
38 pub options: HashMap<String, serde_json::Value>,
39}
40
41impl RenderHints {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 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 pub fn with_x_column(mut self, column: impl Into<String>) -> Self {
55 self.x_column = Some(column.into());
56 self
57 }
58
59 pub fn with_y_column(mut self, column: impl Into<String>) -> Self {
61 self.y_column = Some(column.into());
62 self
63 }
64
65 pub fn with_color_column(mut self, column: impl Into<String>) -> Self {
67 self.color_column = Some(column.into());
68 self
69 }
70
71 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
78pub trait ContentPlugin: Send + Sync {
83 fn content_type(&self) -> ContentTypeId;
85
86 fn schema(&self) -> ContentSchema;
88
89 fn parse(&self, data: &[u8]) -> Result<BoxedContent>;
96
97 fn serialize(&self, content: &dyn ServeableContent) -> Result<Vec<u8>>;
103
104 fn render_hints(&self) -> RenderHints;
106
107 fn version(&self) -> &str;
109
110 fn name(&self) -> &str;
112
113 fn description(&self) -> &str;
115}
116
117pub struct PluginRegistry {
119 plugins: HashMap<ContentTypeId, Box<dyn ContentPlugin>>,
120}
121
122impl PluginRegistry {
123 pub fn new() -> Self {
125 let mut registry = Self {
126 plugins: HashMap::new(),
127 };
128
129 registry.register(Box::new(DatasetPlugin::new()));
131 registry.register(Box::new(RawPlugin::new()));
132
133 registry
134 }
135
136 pub fn register(&mut self, plugin: Box<dyn ContentPlugin>) {
138 self.plugins.insert(plugin.content_type(), plugin);
139 }
140
141 pub fn get(&self, content_type: &ContentTypeId) -> Option<&dyn ContentPlugin> {
143 self.plugins.get(content_type).map(|p| p.as_ref())
144 }
145
146 pub fn content_types(&self) -> Vec<ContentTypeId> {
148 self.plugins.keys().cloned().collect()
149 }
150
151 pub fn has(&self, content_type: &ContentTypeId) -> bool {
153 self.plugins.contains_key(content_type)
154 }
155
156 pub fn len(&self) -> usize {
158 self.plugins.len()
159 }
160
161 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
173pub struct DatasetPlugin;
179
180impl DatasetPlugin {
181 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_pre_configuration!(data);
228 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 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 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
271struct 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 self.dataset.to_parquet_bytes()
324 }
325}
326
327pub struct RawPlugin;
329
330impl RawPlugin {
331 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#[allow(dead_code)]
401pub struct CoursePlugin;
402
403impl CoursePlugin {
404 #[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 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#[allow(dead_code)]
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CourseContent {
508 pub id: String,
510 pub title: String,
512 pub description: String,
514 #[serde(default)]
516 pub short_description: String,
517 #[serde(default)]
519 pub categories: Vec<String>,
520 pub weeks: u32,
522 #[serde(default)]
524 pub featured: bool,
525 #[serde(default)]
527 pub difficulty: Option<String>,
528 #[serde(default)]
530 pub prerequisites: Vec<String>,
531 #[serde(default)]
533 pub outline: Option<CourseOutline>,
534}
535
536#[allow(dead_code)]
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct CourseOutline {
540 pub title: String,
542 #[serde(default)]
544 pub weeks: Vec<Week>,
545}
546
547#[allow(dead_code)]
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct Week {
551 pub number: u32,
553 pub title: String,
555 #[serde(default)]
557 pub lessons: Vec<Lesson>,
558}
559
560#[allow(dead_code)]
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct Lesson {
564 pub number: String,
566 pub title: String,
568 #[serde(default)]
570 pub assets: Vec<Asset>,
571}
572
573#[allow(dead_code)]
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct Asset {
577 pub filename: String,
579 #[serde(rename = "type")]
581 pub kind: AssetType,
582 #[serde(default)]
584 pub description: Option<String>,
585}
586
587#[allow(dead_code)]
589#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
590#[serde(rename_all = "lowercase")]
591pub enum AssetType {
592 Video,
594 KeyTerms,
596 Quiz,
598 Lab,
600 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 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#[allow(dead_code)]
688pub struct BookPlugin;
689
690impl BookPlugin {
691 #[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#[allow(dead_code)]
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct BookContent {
783 pub id: String,
785 pub title: String,
787 pub author: String,
789 #[serde(default)]
791 pub description: String,
792 #[serde(default)]
794 pub version: String,
795 #[serde(default)]
797 pub source_url: Option<String>,
798 #[serde(default)]
800 pub settings: Option<BookSettings>,
801 #[serde(default)]
803 pub chapters: Vec<Chapter>,
804}
805
806#[allow(dead_code)]
808#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
809#[serde(rename_all = "lowercase")]
810pub enum BookFeature {
811 Terminal,
813 Quizzes,
815 Labs,
817 Progress,
819 Bookmarks,
821}
822
823#[allow(dead_code)]
825#[derive(Debug, Clone, Serialize, Deserialize, Default)]
826pub struct BookSettings {
827 #[serde(default)]
829 pub features: std::collections::HashSet<BookFeature>,
830 #[serde(default)]
832 pub default_python_version: Option<String>,
833 #[serde(default)]
835 pub required_packages: Vec<String>,
836 #[serde(default)]
838 pub navigation_type: Option<String>,
839}
840
841#[allow(dead_code)]
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct Chapter {
845 pub id: String,
847 pub title: String,
849 #[serde(default)]
851 pub order: u32,
852 #[serde(default)]
854 pub source_file: Option<String>,
855 #[serde(default)]
857 pub components: Vec<ChapterComponent>,
858 #[serde(default)]
860 pub settings: Option<ChapterSettings>,
861}
862
863#[allow(dead_code)]
865#[derive(Debug, Clone, Serialize, Deserialize, Default)]
866pub struct ChapterSettings {
867 #[serde(default)]
869 pub estimated_time: Option<String>,
870 #[serde(default)]
872 pub difficulty: Option<String>,
873 #[serde(default)]
875 pub prerequisites: Vec<String>,
876}
877
878#[allow(dead_code)]
880#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct ChapterComponent {
882 #[serde(rename = "type")]
884 pub kind: ComponentType,
885 pub id: String,
887 #[serde(default)]
889 pub position: Option<String>,
890 #[serde(default)]
892 pub config: Option<serde_json::Value>,
893}
894
895#[allow(dead_code)]
897#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
898#[serde(rename_all = "lowercase")]
899pub enum ComponentType {
900 Terminal,
902 Quiz,
904 Lab,
906 Editor,
908 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 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 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 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 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 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 #[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 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); }
1564}