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