1use std::path::Path;
33use std::str::FromStr;
34
35use serde::{Deserialize, Serialize};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum PkgType {
51 Runnable,
53 Library,
55}
56
57impl std::fmt::Display for PkgType {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::Runnable => write!(f, "runnable"),
61 Self::Library => write!(f, "library"),
62 }
63 }
64}
65
66impl FromStr for PkgType {
67 type Err = String;
68
69 fn from_str(s: &str) -> Result<Self, Self::Err> {
70 match s {
71 "runnable" => Ok(Self::Runnable),
72 "library" => Ok(Self::Library),
73 other => Err(format!("unknown package type: {other:?}")),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum TypeSource {
91 Explicit,
93 AutoDetectedRunnable,
96 AutoDetectedLibrary,
100}
101
102impl FromStr for TypeSource {
103 type Err = String;
104
105 fn from_str(s: &str) -> Result<Self, Self::Err> {
106 match s {
107 "explicit" => Ok(Self::Explicit),
108 "auto_detected_runnable" => Ok(Self::AutoDetectedRunnable),
109 "auto_detected_library" => Ok(Self::AutoDetectedLibrary),
110 other => Err(format!("unknown type_source: {other:?}")),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
121pub struct PkgEntity {
122 pub name: String,
123 #[serde(default)]
124 pub version: Option<String>,
125 #[serde(default)]
126 pub description: Option<String>,
127 #[serde(default)]
128 pub category: Option<String>,
129 #[serde(default)]
130 pub docstring: Option<String>,
131 #[serde(default)]
132 pub tags: Option<Vec<String>>,
133 #[serde(default, rename = "type")]
136 pub pkg_type: Option<PkgType>,
137 #[serde(default)]
141 pub type_source: Option<TypeSource>,
142}
143
144impl PkgEntity {
145 pub fn parse_from_init_lua(path: &Path) -> Option<Self> {
162 let content = std::fs::read_to_string(path).ok()?;
163 let parsed = parse_meta(&content)?;
164 let docstring = extract_docstring_from(&content);
165 let (pkg_type, type_source) = match parsed.pkg_type {
169 Some(t) => (Some(t), Some(TypeSource::Explicit)),
170 None => {
171 if detect_has_run(&content) {
172 (
173 Some(PkgType::Runnable),
174 Some(TypeSource::AutoDetectedRunnable),
175 )
176 } else {
177 (
178 Some(PkgType::Library),
179 Some(TypeSource::AutoDetectedLibrary),
180 )
181 }
182 }
183 };
184 Some(PkgEntity {
185 name: parsed.name,
186 version: option_from_str(parsed.version),
187 description: option_from_str(parsed.description),
188 category: option_from_str(parsed.category),
189 docstring: option_from_str(docstring),
190 tags: if parsed.tags.is_empty() {
191 None
192 } else {
193 Some(parsed.tags)
194 },
195 pkg_type,
196 type_source,
197 })
198 }
199}
200
201fn option_from_str(s: String) -> Option<String> {
205 if s.is_empty() {
206 None
207 } else {
208 Some(s)
209 }
210}
211
212fn extract_docstring_from(content: &str) -> String {
216 let mut lines = Vec::new();
217 for line in content.lines() {
218 let trimmed = line.trim_start();
219 if let Some(rest) = trimmed.strip_prefix("---") {
220 lines.push(rest.trim().to_string());
221 } else if trimmed.is_empty() {
222 continue;
223 } else {
224 break;
225 }
226 }
227 lines.join("\n")
228}
229
230struct ParsedMeta {
234 name: String,
235 version: String,
236 description: String,
237 category: String,
238 tags: Vec<String>,
239 pkg_type: Option<PkgType>,
240}
241
242fn detect_has_run(content: &str) -> bool {
253 for line in content.lines() {
254 let effective = line.split("--").next().unwrap_or(line);
256 if effective.contains("M.run") {
257 return true;
258 }
259 }
260 false
261}
262
263fn parse_meta(content: &str) -> Option<ParsedMeta> {
266 let head = content;
267
268 let mut search_from = 0;
272 let meta_start = loop {
273 let rel = head[search_from..].find("M.meta")?;
274 let pos = search_from + rel;
275 let line_start = head[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
276 if !head[line_start..pos].contains("--") {
277 break pos;
278 }
279 search_from = pos + "M.meta".len();
280 };
281 let brace_start = head[meta_start..].find('{')? + meta_start;
282
283 let mut depth = 0;
285 let mut brace_end = None;
286 for (i, ch) in head[brace_start..].char_indices() {
287 match ch {
288 '{' => depth += 1,
289 '}' => {
290 depth -= 1;
291 if depth == 0 {
292 brace_end = Some(brace_start + i);
293 break;
294 }
295 }
296 _ => {}
297 }
298 }
299 let brace_end = brace_end?;
300 let block = &head[brace_start + 1..brace_end];
301
302 let extract = |field: &str| -> String {
303 let mut search_from = 0;
309 while let Some(rel) = block[search_from..].find(field) {
310 let pos = search_from + rel;
311 let word_boundary = pos == 0 || {
312 let prev = block.as_bytes()[pos - 1];
313 !(prev.is_ascii_alphanumeric() || prev == b'_')
314 };
315 if word_boundary {
316 let after = &block[pos + field.len()..];
317 let mut collected = String::new();
318 let mut cursor = 0usize;
319 let mut found_any = false;
320 loop {
321 let rest = &after[cursor..];
322 let Some(q_start_rel) = rest.find('"') else {
323 break;
324 };
325 if found_any {
326 let between = &rest[..q_start_rel];
331 if between.trim() != ".." {
332 break;
333 }
334 }
335 let lit_start = cursor + q_start_rel + 1;
336 let Some(q_end_rel) = after[lit_start..].find('"') else {
337 break;
338 };
339 collected.push_str(&after[lit_start..lit_start + q_end_rel]);
340 cursor = lit_start + q_end_rel + 1;
341 found_any = true;
342 }
343 if found_any {
344 return collected;
345 }
346 }
347 search_from = pos + field.len();
348 }
349 String::new()
350 };
351
352 let name = extract("name");
353 if name.is_empty() {
354 return None;
355 }
356 let tags = extract_string_array(block, "tags");
357 let pkg_type = {
359 let raw = extract("type");
360 if raw.is_empty() {
361 None
362 } else {
363 raw.parse::<PkgType>().ok()
364 }
365 };
366 Some(ParsedMeta {
367 name,
368 version: extract("version"),
369 description: extract("description"),
370 category: extract("category"),
371 tags,
372 pkg_type,
373 })
374}
375
376fn extract_string_array(block: &str, field: &str) -> Vec<String> {
379 let mut result = Vec::new();
380 let mut search_from = 0;
381 while let Some(rel) = block[search_from..].find(field) {
382 let pos = search_from + rel;
383 let word_boundary = pos == 0 || {
384 let prev = block.as_bytes()[pos - 1];
385 !(prev.is_ascii_alphanumeric() || prev == b'_')
386 };
387 if word_boundary {
388 let after = &block[pos + field.len()..];
389 if let Some(brace_start) = after.find('{') {
390 let inner_start = brace_start + 1;
391 let mut depth = 1;
392 let mut brace_end = None;
393 for (i, ch) in after[inner_start..].char_indices() {
394 match ch {
395 '{' => depth += 1,
396 '}' => {
397 depth -= 1;
398 if depth == 0 {
399 brace_end = Some(inner_start + i);
400 break;
401 }
402 }
403 _ => {}
404 }
405 }
406 if let Some(end) = brace_end {
407 let inner = &after[inner_start..end];
408 let mut cursor = 0;
409 while let Some(q_start) = inner[cursor..].find('"') {
410 let lit_start = cursor + q_start + 1;
411 if let Some(q_end) = inner[lit_start..].find('"') {
412 let s = &inner[lit_start..lit_start + q_end];
413 if !s.is_empty() {
414 result.push(s.to_string());
415 }
416 cursor = lit_start + q_end + 1;
417 } else {
418 break;
419 }
420 }
421 }
422 }
423 break;
424 }
425 search_from = pos + field.len();
426 }
427 result
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use std::fs;
434
435 fn write_init_lua(dir: &Path, body: &str) -> std::path::PathBuf {
436 let path = dir.join("init.lua");
437 fs::write(&path, body).unwrap();
438 path
439 }
440
441 #[test]
442 fn parse_flat_meta() {
443 let tmp = tempfile::tempdir().unwrap();
444 let path = write_init_lua(
445 tmp.path(),
446 r#"
447local M = {}
448M.meta = {
449 name = "my_pkg",
450 version = "1.0.0",
451 description = "A test package",
452 category = "reasoning",
453}
454return M
455"#,
456 );
457
458 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
459 assert_eq!(pkg.name, "my_pkg");
460 assert_eq!(pkg.version.as_deref(), Some("1.0.0"));
461 assert_eq!(pkg.description.as_deref(), Some("A test package"));
462 assert_eq!(pkg.category.as_deref(), Some("reasoning"));
463 }
464
465 #[test]
466 fn parse_tags_from_nested_table() {
467 let tmp = tempfile::tempdir().unwrap();
468 let path = write_init_lua(
469 tmp.path(),
470 r#"
471local M = {}
472M.meta = {
473 name = "nested_pkg",
474 tags = { "a", "b" },
475 description = "After nested",
476}
477return M
478"#,
479 );
480
481 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
482 assert_eq!(pkg.name, "nested_pkg");
483 assert_eq!(pkg.description.as_deref(), Some("After nested"));
484 assert_eq!(
485 pkg.tags.as_deref(),
486 Some(vec!["a".to_string(), "b".to_string()].as_slice())
487 );
488 }
489
490 #[test]
491 fn parse_tags_absent() {
492 let tmp = tempfile::tempdir().unwrap();
493 let path = write_init_lua(
494 tmp.path(),
495 r#"
496local M = {}
497M.meta = {
498 name = "no_tags_pkg",
499 description = "No tags",
500}
501return M
502"#,
503 );
504
505 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
506 assert_eq!(pkg.name, "no_tags_pkg");
507 assert!(pkg.tags.is_none());
508 }
509
510 #[test]
511 fn parse_tags_empty_array() {
512 let tmp = tempfile::tempdir().unwrap();
513 let path = write_init_lua(
514 tmp.path(),
515 r#"
516local M = {}
517M.meta = {
518 name = "empty_tags_pkg",
519 tags = {},
520 description = "Empty tags",
521}
522return M
523"#,
524 );
525
526 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
527 assert_eq!(pkg.name, "empty_tags_pkg");
528 assert!(pkg.tags.is_none());
529 }
530
531 #[test]
532 fn parse_concat_string_literals() {
533 let tmp = tempfile::tempdir().unwrap();
534 let path = write_init_lua(
535 tmp.path(),
536 r#"
537local M = {}
538M.meta = {
539 name = "concat_pkg",
540 version = "0.1.0",
541 description = "foo "
542 .. "bar "
543 .. "baz",
544 category = "reasoning",
545}
546return M
547"#,
548 );
549
550 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
551 assert_eq!(pkg.description.as_deref(), Some("foo bar baz"));
552 }
553
554 #[test]
555 fn parse_word_boundary_for_description() {
556 let tmp = tempfile::tempdir().unwrap();
557 let path = write_init_lua(
558 tmp.path(),
559 r#"
560local M = {}
561M.meta = {
562 name = "wb_pkg",
563 short_description = "should not match",
564 description = "correct one",
565}
566return M
567"#,
568 );
569
570 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
571 assert_eq!(pkg.name, "wb_pkg");
572 assert_eq!(pkg.description.as_deref(), Some("correct one"));
573 }
574
575 #[test]
576 fn parse_meta_large_leading_docstring() {
577 let tmp = tempfile::tempdir().unwrap();
578 let mut content = String::new();
579 for i in 0..120 {
580 content.push_str(&format!("--- line {i}: long doc comment\n"));
581 }
582 content.push_str(
583 r#"
584local M = {}
585M.meta = {
586 name = "late_meta_pkg",
587 version = "0.2.0",
588 description = "Located past 2KB",
589 category = "test",
590}
591return M
592"#,
593 );
594 assert!(content.len() > 2048, "fixture should exceed 2KB");
595 let path = write_init_lua(tmp.path(), &content);
596
597 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
598 assert_eq!(pkg.name, "late_meta_pkg");
599 assert_eq!(pkg.version.as_deref(), Some("0.2.0"));
600 assert_eq!(pkg.description.as_deref(), Some("Located past 2KB"));
601 assert_eq!(pkg.category.as_deref(), Some("test"));
602 }
603
604 #[test]
605 fn parse_returns_none_without_meta_block() {
606 let tmp = tempfile::tempdir().unwrap();
610 let path = write_init_lua(
611 tmp.path(),
612 r#"
613--- alc_shapes — type DSL (not a package)
614local M = {}
615return M
616"#,
617 );
618
619 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
620 }
621
622 #[test]
623 fn parse_returns_none_when_name_empty() {
624 let tmp = tempfile::tempdir().unwrap();
625 let path = write_init_lua(
626 tmp.path(),
627 r#"
628local M = {}
629M.meta = {
630 name = "",
631 version = "1.0.0",
632}
633return M
634"#,
635 );
636
637 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
638 }
639
640 #[test]
641 fn parse_returns_none_when_file_missing() {
642 let tmp = tempfile::tempdir().unwrap();
643 let path = tmp.path().join("nonexistent.lua");
644 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
645 }
646
647 #[test]
648 fn extracts_docstring_and_meta() {
649 let tmp = tempfile::tempdir().unwrap();
650 let path = write_init_lua(
651 tmp.path(),
652 r#"--- cascade — Multi-level routing with confidence gating
653--- Based on: "FrugalGPT" (Chen et al., 2023)
654
655local M = {}
656M.meta = {
657 name = "cascade",
658 version = "0.1.0",
659 description = "Multi-level routing",
660 category = "meta",
661}
662return M
663"#,
664 );
665
666 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
667 assert_eq!(pkg.name, "cascade");
668 let doc = pkg.docstring.expect("docstring should be present");
669 assert!(doc.contains("FrugalGPT"));
670 assert!(doc.contains("Multi-level"));
671 assert!(!doc.contains("local M"));
672 }
673
674 #[test]
675 fn docstring_absent_when_no_leading_comments() {
676 let tmp = tempfile::tempdir().unwrap();
677 let path = write_init_lua(
678 tmp.path(),
679 r#"local M = {}
680M.meta = { name = "nodoc" }
681return M
682"#,
683 );
684 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
685 assert!(pkg.docstring.is_none());
686 }
687
688 #[test]
689 fn m_dot_meta_inside_comment_is_ignored() {
690 let tmp = tempfile::tempdir().unwrap();
693 let path = write_init_lua(
694 tmp.path(),
695 r#"
696-- example: M.meta = { name = "decoy" }
697local M = {}
698M.meta = {
699 name = "real",
700}
701return M
702"#,
703 );
704 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
705 assert_eq!(pkg.name, "real");
706 }
707
708 #[test]
709 fn serde_round_trip_preserves_none_vs_empty() {
710 let pkg = PkgEntity {
715 name: "p".into(),
716 version: None,
717 description: Some(String::new()),
718 category: Some("meta".into()),
719 docstring: None,
720 tags: None,
721 pkg_type: None,
722 type_source: None,
723 };
724 let json = serde_json::to_string(&pkg).unwrap();
725 assert!(json.contains("\"version\":null"), "version null: {json}");
726 assert!(
727 json.contains("\"description\":\"\""),
728 "description empty string: {json}"
729 );
730 assert!(
731 json.contains("\"docstring\":null"),
732 "docstring null: {json}"
733 );
734
735 let back: PkgEntity = serde_json::from_str(&json).unwrap();
736 assert_eq!(back, pkg);
737 }
738
739 #[test]
740 fn serde_deserialize_accepts_missing_optional_fields() {
741 let json = r#"{"name":"minimal"}"#;
744 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
745 assert_eq!(pkg.name, "minimal");
746 assert!(pkg.version.is_none());
747 assert!(pkg.description.is_none());
748 assert!(pkg.category.is_none());
749 assert!(pkg.docstring.is_none());
750 }
751
752 #[test]
755 fn parse_type_from_meta() {
756 let tmp = tempfile::tempdir().unwrap();
758 let path = write_init_lua(
759 tmp.path(),
760 r#"
761local M = {}
762M.meta = {
763 name = "lib_pkg",
764 type = "library",
765 version = "1.0.0",
766}
767return M
768"#,
769 );
770
771 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
772 assert_eq!(pkg.name, "lib_pkg");
773 assert_eq!(pkg.pkg_type, Some(PkgType::Library));
774 }
775
776 #[test]
777 fn parse_type_runnable_explicit() {
778 let tmp = tempfile::tempdir().unwrap();
780 let path = write_init_lua(
781 tmp.path(),
782 r#"
783local M = {}
784M.meta = {
785 name = "run_pkg",
786 type = "runnable",
787}
788function M.run(ctx) end
789return M
790"#,
791 );
792
793 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
794 assert_eq!(pkg.pkg_type, Some(PkgType::Runnable));
795 }
796
797 #[test]
798 fn auto_detect_type_from_m_run() {
799 let tmp = tempfile::tempdir().unwrap();
801 let path = write_init_lua(
802 tmp.path(),
803 r#"
804local M = {}
805M.meta = {
806 name = "auto_run_pkg",
807}
808function M.run(ctx)
809 return alc.llm("hello")
810end
811return M
812"#,
813 );
814
815 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
816 assert_eq!(
817 pkg.pkg_type,
818 Some(PkgType::Runnable),
819 "M.run present → Runnable"
820 );
821 }
822
823 #[test]
824 fn auto_detect_type_library_no_m_run() {
825 let tmp = tempfile::tempdir().unwrap();
827 let path = write_init_lua(
828 tmp.path(),
829 r#"
830local M = {}
831M.meta = {
832 name = "auto_lib_pkg",
833 description = "A pure library with no run entry point",
834}
835function M.create(opts)
836 return {}
837end
838return M
839"#,
840 );
841
842 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
843 assert_eq!(pkg.pkg_type, Some(PkgType::Library), "no M.run → Library");
844 }
845
846 #[test]
847 fn detect_has_run_ignores_comments() {
848 assert!(
850 !detect_has_run("-- M.run = function(ctx) end\nlocal M = {}\n"),
851 "commented-out M.run should not be detected"
852 );
853 assert!(
856 detect_has_run("local M = {}\nM.run = function(ctx) end\n"),
857 "real M.run should be detected"
858 );
859 assert!(
862 !detect_has_run("local x = 1 -- M.run is described here\n"),
863 "M.run inside inline comment should not be detected"
864 );
865 }
866
867 #[test]
868 fn serde_round_trip_with_pkg_type() {
869 let pkg = PkgEntity {
871 name: "lib".into(),
872 version: Some("0.1.0".into()),
873 description: None,
874 category: None,
875 docstring: None,
876 tags: None,
877 pkg_type: Some(PkgType::Library),
878 type_source: None,
879 };
880 let json = serde_json::to_string(&pkg).unwrap();
881 assert!(
882 json.contains("\"type\":\"library\""),
883 "wire key must be 'type': {json}"
884 );
885 let back: PkgEntity = serde_json::from_str(&json).unwrap();
886 assert_eq!(back.pkg_type, Some(PkgType::Library));
887 assert_eq!(back, pkg);
888 }
889
890 #[test]
891 fn serde_deserialize_missing_pkg_type() {
892 let json = r#"{"name":"legacy_pkg","version":"1.0.0"}"#;
894 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
895 assert_eq!(pkg.name, "legacy_pkg");
896 assert!(
897 pkg.pkg_type.is_none(),
898 "missing 'type' key must deserialize as None"
899 );
900 }
901
902 #[test]
905 fn parse_explicit_type_sets_source_explicit() {
906 let tmp = tempfile::tempdir().unwrap();
908 let path = write_init_lua(
909 tmp.path(),
910 r#"
911local M = {}
912M.meta = {
913 name = "explicit_lib",
914 type = "library",
915 version = "1.0.0",
916}
917return M
918"#,
919 );
920
921 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
922 assert_eq!(pkg.pkg_type, Some(PkgType::Library));
923 assert_eq!(
924 pkg.type_source,
925 Some(TypeSource::Explicit),
926 "explicit M.meta.type must yield TypeSource::Explicit"
927 );
928 }
929
930 #[test]
931 fn parse_auto_detect_runnable_sets_source() {
932 let tmp = tempfile::tempdir().unwrap();
934 let path = write_init_lua(
935 tmp.path(),
936 r#"
937local M = {}
938M.meta = {
939 name = "auto_run",
940 version = "0.1.0",
941}
942function M.run(ctx)
943 return alc.llm("hello")
944end
945return M
946"#,
947 );
948
949 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
950 assert_eq!(pkg.pkg_type, Some(PkgType::Runnable));
951 assert_eq!(
952 pkg.type_source,
953 Some(TypeSource::AutoDetectedRunnable),
954 "M.run present without explicit type must yield AutoDetectedRunnable"
955 );
956 }
957
958 #[test]
959 fn parse_auto_detect_library_sets_source() {
960 let tmp = tempfile::tempdir().unwrap();
962 let path = write_init_lua(
963 tmp.path(),
964 r#"
965local M = {}
966M.meta = {
967 name = "auto_lib",
968 description = "A pure library",
969}
970function M.create(opts)
971 return {}
972end
973return M
974"#,
975 );
976
977 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
978 assert_eq!(pkg.pkg_type, Some(PkgType::Library));
979 assert_eq!(
980 pkg.type_source,
981 Some(TypeSource::AutoDetectedLibrary),
982 "no M.run and no explicit type must yield AutoDetectedLibrary"
983 );
984 }
985
986 #[test]
987 fn serde_round_trip_with_type_source() {
988 let tmp = tempfile::tempdir().unwrap();
991 let path = write_init_lua(
992 tmp.path(),
993 r#"
994local M = {}
995M.meta = {
996 name = "rt_lib",
997}
998return M
999"#,
1000 );
1001 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
1002 assert_eq!(pkg.type_source, Some(TypeSource::AutoDetectedLibrary));
1003
1004 let json = serde_json::to_string(&pkg).unwrap();
1005 assert!(
1006 json.contains("\"type_source\":\"auto_detected_library\""),
1007 "wire string must be 'auto_detected_library': {json}"
1008 );
1009
1010 let back: PkgEntity = serde_json::from_str(&json).unwrap();
1011 assert_eq!(back.type_source, Some(TypeSource::AutoDetectedLibrary));
1012 assert_eq!(back, pkg);
1013 }
1014
1015 #[test]
1016 fn serde_deserialize_missing_type_source_is_none() {
1017 let json = r#"{"name":"legacy_no_source","type":"library"}"#;
1020 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
1021 assert_eq!(pkg.name, "legacy_no_source");
1022 assert_eq!(pkg.pkg_type, Some(PkgType::Library));
1023 assert!(
1024 pkg.type_source.is_none(),
1025 "missing 'type_source' key must deserialize as None (legacy compat)"
1026 );
1027 }
1028}