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)]
88#[serde(rename_all = "snake_case")]
89pub enum TypeSource {
90 AutoDetectedRunnable,
93 AutoDetectedLibrary,
96}
97
98impl FromStr for TypeSource {
99 type Err = String;
100
101 fn from_str(s: &str) -> Result<Self, Self::Err> {
102 match s {
103 "auto_detected_runnable" => Ok(Self::AutoDetectedRunnable),
104 "auto_detected_library" => Ok(Self::AutoDetectedLibrary),
105 other => Err(format!("unknown type_source: {other:?}")),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
116pub struct PkgEntity {
117 pub name: String,
118 #[serde(default)]
119 pub version: Option<String>,
120 #[serde(default)]
121 pub description: Option<String>,
122 #[serde(default)]
123 pub category: Option<String>,
124 #[serde(default)]
125 pub docstring: Option<String>,
126 #[serde(default)]
127 pub tags: Option<Vec<String>>,
128 #[serde(default, rename = "type")]
131 pub pkg_type: Option<PkgType>,
132 #[serde(default, deserialize_with = "deserialize_type_source_lenient")]
140 pub type_source: Option<TypeSource>,
141}
142
143fn deserialize_type_source_lenient<'de, D>(d: D) -> Result<Option<TypeSource>, D::Error>
150where
151 D: serde::Deserializer<'de>,
152{
153 let s: Option<String> = Option::deserialize(d)?;
154 Ok(s.and_then(|v| TypeSource::from_str(&v).ok()))
155}
156
157impl PkgEntity {
158 pub fn parse_from_init_lua(path: &Path) -> Option<Self> {
175 let content = std::fs::read_to_string(path).ok()?;
176 let parsed = parse_meta(&content)?;
177 let docstring = extract_docstring_from(&content);
178 let pkg_type: Option<PkgType> = None;
182 let type_source: Option<TypeSource> = None;
183 Some(PkgEntity {
184 name: parsed.name,
185 version: option_from_str(parsed.version),
186 description: option_from_str(parsed.description),
187 category: option_from_str(parsed.category),
188 docstring: option_from_str(docstring),
189 tags: if parsed.tags.is_empty() {
190 None
191 } else {
192 Some(parsed.tags)
193 },
194 pkg_type,
195 type_source,
196 })
197 }
198}
199
200fn option_from_str(s: String) -> Option<String> {
204 if s.is_empty() {
205 None
206 } else {
207 Some(s)
208 }
209}
210
211fn extract_docstring_from(content: &str) -> String {
215 let mut lines = Vec::new();
216 for line in content.lines() {
217 let trimmed = line.trim_start();
218 if let Some(rest) = trimmed.strip_prefix("---") {
219 lines.push(rest.trim().to_string());
220 } else if trimmed.is_empty() {
221 continue;
222 } else {
223 break;
224 }
225 }
226 lines.join("\n")
227}
228
229struct ParsedMeta {
233 name: String,
234 version: String,
235 description: String,
236 category: String,
237 tags: Vec<String>,
238}
239
240fn parse_meta(content: &str) -> Option<ParsedMeta> {
243 let head = content;
244
245 let mut search_from = 0;
249 let meta_start = loop {
250 let rel = head[search_from..].find("M.meta")?;
251 let pos = search_from + rel;
252 let line_start = head[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
253 if !head[line_start..pos].contains("--") {
254 break pos;
255 }
256 search_from = pos + "M.meta".len();
257 };
258 let brace_start = head[meta_start..].find('{')? + meta_start;
259
260 let mut depth = 0;
262 let mut brace_end = None;
263 for (i, ch) in head[brace_start..].char_indices() {
264 match ch {
265 '{' => depth += 1,
266 '}' => {
267 depth -= 1;
268 if depth == 0 {
269 brace_end = Some(brace_start + i);
270 break;
271 }
272 }
273 _ => {}
274 }
275 }
276 let brace_end = brace_end?;
277 let block = &head[brace_start + 1..brace_end];
278
279 let extract = |field: &str| -> String {
280 let mut search_from = 0;
286 while let Some(rel) = block[search_from..].find(field) {
287 let pos = search_from + rel;
288 let word_boundary = pos == 0 || {
289 let prev = block.as_bytes()[pos - 1];
290 !(prev.is_ascii_alphanumeric() || prev == b'_')
291 };
292 if word_boundary {
293 let after = &block[pos + field.len()..];
294 let mut collected = String::new();
295 let mut cursor = 0usize;
296 let mut found_any = false;
297 loop {
298 let rest = &after[cursor..];
299 let Some(q_start_rel) = rest.find('"') else {
300 break;
301 };
302 if found_any {
303 let between = &rest[..q_start_rel];
308 if between.trim() != ".." {
309 break;
310 }
311 }
312 let lit_start = cursor + q_start_rel + 1;
313 let Some(q_end_rel) = after[lit_start..].find('"') else {
314 break;
315 };
316 collected.push_str(&after[lit_start..lit_start + q_end_rel]);
317 cursor = lit_start + q_end_rel + 1;
318 found_any = true;
319 }
320 if found_any {
321 return collected;
322 }
323 }
324 search_from = pos + field.len();
325 }
326 String::new()
327 };
328
329 let name = extract("name");
330 if name.is_empty() {
331 return None;
332 }
333 let tags = extract_string_array(block, "tags");
334 Some(ParsedMeta {
335 name,
336 version: extract("version"),
337 description: extract("description"),
338 category: extract("category"),
339 tags,
340 })
341}
342
343fn extract_string_array(block: &str, field: &str) -> Vec<String> {
346 let mut result = Vec::new();
347 let mut search_from = 0;
348 while let Some(rel) = block[search_from..].find(field) {
349 let pos = search_from + rel;
350 let word_boundary = pos == 0 || {
351 let prev = block.as_bytes()[pos - 1];
352 !(prev.is_ascii_alphanumeric() || prev == b'_')
353 };
354 if word_boundary {
355 let after = &block[pos + field.len()..];
356 if let Some(brace_start) = after.find('{') {
357 let inner_start = brace_start + 1;
358 let mut depth = 1;
359 let mut brace_end = None;
360 for (i, ch) in after[inner_start..].char_indices() {
361 match ch {
362 '{' => depth += 1,
363 '}' => {
364 depth -= 1;
365 if depth == 0 {
366 brace_end = Some(inner_start + i);
367 break;
368 }
369 }
370 _ => {}
371 }
372 }
373 if let Some(end) = brace_end {
374 let inner = &after[inner_start..end];
375 let mut cursor = 0;
376 while let Some(q_start) = inner[cursor..].find('"') {
377 let lit_start = cursor + q_start + 1;
378 if let Some(q_end) = inner[lit_start..].find('"') {
379 let s = &inner[lit_start..lit_start + q_end];
380 if !s.is_empty() {
381 result.push(s.to_string());
382 }
383 cursor = lit_start + q_end + 1;
384 } else {
385 break;
386 }
387 }
388 }
389 }
390 break;
391 }
392 search_from = pos + field.len();
393 }
394 result
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use std::fs;
401
402 fn write_init_lua(dir: &Path, body: &str) -> std::path::PathBuf {
403 let path = dir.join("init.lua");
404 fs::write(&path, body).unwrap();
405 path
406 }
407
408 #[test]
409 fn parse_flat_meta() {
410 let tmp = tempfile::tempdir().unwrap();
411 let path = write_init_lua(
412 tmp.path(),
413 r#"
414local M = {}
415M.meta = {
416 name = "my_pkg",
417 version = "1.0.0",
418 description = "A test package",
419 category = "reasoning",
420}
421return M
422"#,
423 );
424
425 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
426 assert_eq!(pkg.name, "my_pkg");
427 assert_eq!(pkg.version.as_deref(), Some("1.0.0"));
428 assert_eq!(pkg.description.as_deref(), Some("A test package"));
429 assert_eq!(pkg.category.as_deref(), Some("reasoning"));
430 }
431
432 #[test]
433 fn parse_tags_from_nested_table() {
434 let tmp = tempfile::tempdir().unwrap();
435 let path = write_init_lua(
436 tmp.path(),
437 r#"
438local M = {}
439M.meta = {
440 name = "nested_pkg",
441 tags = { "a", "b" },
442 description = "After nested",
443}
444return M
445"#,
446 );
447
448 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
449 assert_eq!(pkg.name, "nested_pkg");
450 assert_eq!(pkg.description.as_deref(), Some("After nested"));
451 assert_eq!(
452 pkg.tags.as_deref(),
453 Some(vec!["a".to_string(), "b".to_string()].as_slice())
454 );
455 }
456
457 #[test]
458 fn parse_tags_absent() {
459 let tmp = tempfile::tempdir().unwrap();
460 let path = write_init_lua(
461 tmp.path(),
462 r#"
463local M = {}
464M.meta = {
465 name = "no_tags_pkg",
466 description = "No tags",
467}
468return M
469"#,
470 );
471
472 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
473 assert_eq!(pkg.name, "no_tags_pkg");
474 assert!(pkg.tags.is_none());
475 }
476
477 #[test]
478 fn parse_tags_empty_array() {
479 let tmp = tempfile::tempdir().unwrap();
480 let path = write_init_lua(
481 tmp.path(),
482 r#"
483local M = {}
484M.meta = {
485 name = "empty_tags_pkg",
486 tags = {},
487 description = "Empty tags",
488}
489return M
490"#,
491 );
492
493 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
494 assert_eq!(pkg.name, "empty_tags_pkg");
495 assert!(pkg.tags.is_none());
496 }
497
498 #[test]
499 fn parse_concat_string_literals() {
500 let tmp = tempfile::tempdir().unwrap();
501 let path = write_init_lua(
502 tmp.path(),
503 r#"
504local M = {}
505M.meta = {
506 name = "concat_pkg",
507 version = "0.1.0",
508 description = "foo "
509 .. "bar "
510 .. "baz",
511 category = "reasoning",
512}
513return M
514"#,
515 );
516
517 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
518 assert_eq!(pkg.description.as_deref(), Some("foo bar baz"));
519 }
520
521 #[test]
522 fn parse_word_boundary_for_description() {
523 let tmp = tempfile::tempdir().unwrap();
524 let path = write_init_lua(
525 tmp.path(),
526 r#"
527local M = {}
528M.meta = {
529 name = "wb_pkg",
530 short_description = "should not match",
531 description = "correct one",
532}
533return M
534"#,
535 );
536
537 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
538 assert_eq!(pkg.name, "wb_pkg");
539 assert_eq!(pkg.description.as_deref(), Some("correct one"));
540 }
541
542 #[test]
543 fn parse_meta_large_leading_docstring() {
544 let tmp = tempfile::tempdir().unwrap();
545 let mut content = String::new();
546 for i in 0..120 {
547 content.push_str(&format!("--- line {i}: long doc comment\n"));
548 }
549 content.push_str(
550 r#"
551local M = {}
552M.meta = {
553 name = "late_meta_pkg",
554 version = "0.2.0",
555 description = "Located past 2KB",
556 category = "test",
557}
558return M
559"#,
560 );
561 assert!(content.len() > 2048, "fixture should exceed 2KB");
562 let path = write_init_lua(tmp.path(), &content);
563
564 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
565 assert_eq!(pkg.name, "late_meta_pkg");
566 assert_eq!(pkg.version.as_deref(), Some("0.2.0"));
567 assert_eq!(pkg.description.as_deref(), Some("Located past 2KB"));
568 assert_eq!(pkg.category.as_deref(), Some("test"));
569 }
570
571 #[test]
572 fn parse_returns_none_without_meta_block() {
573 let tmp = tempfile::tempdir().unwrap();
577 let path = write_init_lua(
578 tmp.path(),
579 r#"
580--- alc_shapes — type DSL (not a package)
581local M = {}
582return M
583"#,
584 );
585
586 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
587 }
588
589 #[test]
590 fn parse_returns_none_when_name_empty() {
591 let tmp = tempfile::tempdir().unwrap();
592 let path = write_init_lua(
593 tmp.path(),
594 r#"
595local M = {}
596M.meta = {
597 name = "",
598 version = "1.0.0",
599}
600return M
601"#,
602 );
603
604 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
605 }
606
607 #[test]
608 fn parse_returns_none_when_file_missing() {
609 let tmp = tempfile::tempdir().unwrap();
610 let path = tmp.path().join("nonexistent.lua");
611 assert!(PkgEntity::parse_from_init_lua(&path).is_none());
612 }
613
614 #[test]
615 fn extracts_docstring_and_meta() {
616 let tmp = tempfile::tempdir().unwrap();
617 let path = write_init_lua(
618 tmp.path(),
619 r#"--- cascade — Multi-level routing with confidence gating
620--- Based on: "FrugalGPT" (Chen et al., 2023)
621
622local M = {}
623M.meta = {
624 name = "cascade",
625 version = "0.1.0",
626 description = "Multi-level routing",
627 category = "meta",
628}
629return M
630"#,
631 );
632
633 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
634 assert_eq!(pkg.name, "cascade");
635 let doc = pkg.docstring.expect("docstring should be present");
636 assert!(doc.contains("FrugalGPT"));
637 assert!(doc.contains("Multi-level"));
638 assert!(!doc.contains("local M"));
639 }
640
641 #[test]
642 fn docstring_absent_when_no_leading_comments() {
643 let tmp = tempfile::tempdir().unwrap();
644 let path = write_init_lua(
645 tmp.path(),
646 r#"local M = {}
647M.meta = { name = "nodoc" }
648return M
649"#,
650 );
651 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
652 assert!(pkg.docstring.is_none());
653 }
654
655 #[test]
656 fn m_dot_meta_inside_comment_is_ignored() {
657 let tmp = tempfile::tempdir().unwrap();
660 let path = write_init_lua(
661 tmp.path(),
662 r#"
663-- example: M.meta = { name = "decoy" }
664local M = {}
665M.meta = {
666 name = "real",
667}
668return M
669"#,
670 );
671 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
672 assert_eq!(pkg.name, "real");
673 }
674
675 #[test]
676 fn serde_round_trip_preserves_none_vs_empty() {
677 let pkg = PkgEntity {
682 name: "p".into(),
683 version: None,
684 description: Some(String::new()),
685 category: Some("meta".into()),
686 docstring: None,
687 tags: None,
688 pkg_type: None,
689 type_source: None,
690 };
691 let json = serde_json::to_string(&pkg).unwrap();
692 assert!(json.contains("\"version\":null"), "version null: {json}");
693 assert!(
694 json.contains("\"description\":\"\""),
695 "description empty string: {json}"
696 );
697 assert!(
698 json.contains("\"docstring\":null"),
699 "docstring null: {json}"
700 );
701
702 let back: PkgEntity = serde_json::from_str(&json).unwrap();
703 assert_eq!(back, pkg);
704 }
705
706 #[test]
707 fn serde_deserialize_accepts_missing_optional_fields() {
708 let json = r#"{"name":"minimal"}"#;
711 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
712 assert_eq!(pkg.name, "minimal");
713 assert!(pkg.version.is_none());
714 assert!(pkg.description.is_none());
715 assert!(pkg.category.is_none());
716 assert!(pkg.docstring.is_none());
717 }
718
719 #[test]
722 fn parse_from_init_lua_pkg_type_is_none() {
723 let tmp = tempfile::tempdir().unwrap();
726 let path = write_init_lua(
727 tmp.path(),
728 r#"
729local M = {}
730M.meta = {
731 name = "any_pkg",
732 version = "1.0.0",
733}
734function M.run(ctx) end
735return M
736"#,
737 );
738
739 let pkg = PkgEntity::parse_from_init_lua(&path).expect("should parse");
740 assert_eq!(pkg.name, "any_pkg");
741 assert!(
742 pkg.pkg_type.is_none(),
743 "parse_from_init_lua must not set pkg_type"
744 );
745 assert!(
746 pkg.type_source.is_none(),
747 "parse_from_init_lua must not set type_source"
748 );
749 }
750
751 #[test]
752 fn serde_round_trip_with_pkg_type() {
753 let pkg = PkgEntity {
755 name: "lib".into(),
756 version: Some("0.1.0".into()),
757 description: None,
758 category: None,
759 docstring: None,
760 tags: None,
761 pkg_type: Some(PkgType::Library),
762 type_source: None,
763 };
764 let json = serde_json::to_string(&pkg).unwrap();
765 assert!(
766 json.contains("\"type\":\"library\""),
767 "wire key must be 'type': {json}"
768 );
769 let back: PkgEntity = serde_json::from_str(&json).unwrap();
770 assert_eq!(back.pkg_type, Some(PkgType::Library));
771 assert_eq!(back, pkg);
772 }
773
774 #[test]
775 fn serde_deserialize_missing_pkg_type() {
776 let json = r#"{"name":"legacy_pkg","version":"1.0.0"}"#;
778 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
779 assert_eq!(pkg.name, "legacy_pkg");
780 assert!(
781 pkg.pkg_type.is_none(),
782 "missing 'type' key must deserialize as None"
783 );
784 }
785
786 #[test]
789 fn serde_round_trip_with_type_source() {
790 let pkg = PkgEntity {
792 name: "rt_lib".into(),
793 version: None,
794 description: None,
795 category: None,
796 docstring: None,
797 tags: None,
798 pkg_type: Some(PkgType::Library),
799 type_source: Some(TypeSource::AutoDetectedLibrary),
800 };
801 let json = serde_json::to_string(&pkg).unwrap();
802 assert!(
803 json.contains("\"type_source\":\"auto_detected_library\""),
804 "wire string must be 'auto_detected_library': {json}"
805 );
806
807 let back: PkgEntity = serde_json::from_str(&json).unwrap();
808 assert_eq!(back.type_source, Some(TypeSource::AutoDetectedLibrary));
809 assert_eq!(back, pkg);
810 }
811
812 #[test]
813 fn serde_deserialize_missing_type_source_is_none() {
814 let json = r#"{"name":"legacy_no_source","type":"library"}"#;
817 let pkg: PkgEntity = serde_json::from_str(json).unwrap();
818 assert_eq!(pkg.name, "legacy_no_source");
819 assert_eq!(pkg.pkg_type, Some(PkgType::Library));
820 assert!(
821 pkg.type_source.is_none(),
822 "missing 'type_source' key must deserialize as None (legacy compat)"
823 );
824 }
825
826 #[test]
827 fn serde_deserialize_explicit_type_source_degrades_to_none() {
828 let json = r#"{"name":"legacy_explicit","type_source":"explicit"}"#;
830 let pkg: PkgEntity = serde_json::from_str(json).expect("must not error on unknown variant");
831 assert!(
832 pkg.type_source.is_none(),
833 "\"explicit\" must degrade to None, got: {:?}",
834 pkg.type_source
835 );
836 }
837
838 #[test]
839 fn serde_deserialize_known_type_source_parses_correctly() {
840 let json = r#"{"name":"lib_auto","type_source":"auto_detected_library"}"#;
841 let pkg: PkgEntity = serde_json::from_str(json).expect("must not error");
842 assert_eq!(pkg.type_source, Some(TypeSource::AutoDetectedLibrary));
843 }
844}