Skip to main content

algocline_core/
pkg.rs

1//! Canonical projection of a Lua package's `M.meta` block.
2//!
3//! `PkgEntity` captures the identity portion of an algocline package: the
4//! fields users rely on to discover, categorize, and version-track a package.
5//! It is the single source of truth for "what is this package?" and is
6//! flattened into higher-level records (`IndexEntry`, `SearchResult`,
7//! `hub_info` responses) so the JSON wire shape stays consistent across the
8//! Hub, the manifest, and project lockfiles.
9//!
10//! ## Parsing contract
11//!
12//! [`PkgEntity::parse_from_init_lua`] is a non-Lua-VM best-effort parser over
13//! the `M.meta = { ... }` block of an `init.lua`. It deliberately only
14//! supports flat key–value pairs with (possibly concatenated) string
15//! literals; nested tables (e.g. `tags = { ... }`) are skipped via
16//! brace-depth tracking. When `M.meta.name` is absent or empty the parser
17//! returns `None` — this is the **inclusion gate** for hub indexing. The
18//! caller (`build_index` in `algocline-app::service::hub`) is expected to
19//! drop `None` directories silently so "draft" directories like
20//! `alc_shapes/` (a type DSL library, not an algocline package) do not
21//! pollute the hub index.
22//!
23//! ## Wire format
24//!
25//! `Option` fields use `#[serde(default)]` but deliberately do **not** use
26//! `skip_serializing_if`. A missing field deserializes as `None` and
27//! serializes back as `null`. This preserves the key-presence guarantee of
28//! the current `hub_index.json` consumers (Bundled-side doc generation,
29//! `README.md` package-count scripts) so they do not break on field
30//! absence.
31
32use std::path::Path;
33use std::str::FromStr;
34
35use serde::{Deserialize, Serialize};
36
37/// Package type discriminator: runnable (has `M.run`) or library (API surface only).
38///
39/// Used by the adviser and eval entry points to route packages correctly:
40/// - `Runnable` packages are executed via `M.run(ctx)`.
41/// - `Library` packages expose an API surface and must not be executed via `M.run`.
42///
43/// Wire format: `"runnable"` / `"library"` (lowercase via `#[serde(rename_all = "lowercase")]`).
44///
45/// Auto-detection: when `M.meta.type` is absent, the Lua VM path uses
46/// `type(pkg.run) == "function"` at runtime (canonical). Type is set via
47/// VM eval (`LUA_TYPE_AUTODETECT`) and attached to the entity by the caller.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum PkgType {
51    /// Package has a `M.run(ctx)` entry point — can be executed via `alc_advice`.
52    Runnable,
53    /// Package exposes an API surface only — must not be invoked via `M.run`.
54    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/// Records how `pkg_type` was determined for a package.
79///
80/// This is stored alongside `PkgType` in `PkgEntity.type_source` so
81/// downstream consumers (`alc_pkg_list`) can inspect the provenance of the
82/// type determination.
83///
84/// Wire format: `"auto_detected_runnable"` / `"auto_detected_library"`
85/// (snake_case via `#[serde(rename_all = "snake_case")]`).
86/// Type is always determined by VM eval (`LUA_TYPE_AUTODETECT`).
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum TypeSource {
90    /// `M.meta.type` was absent; the package was classified as `runnable`
91    /// because `M.run` is defined (Lua VM detection via `LUA_TYPE_AUTODETECT`).
92    AutoDetectedRunnable,
93    /// `M.meta.type` was absent; the package was classified as `library`
94    /// because no `M.run` was found (Lua VM detection via `LUA_TYPE_AUTODETECT`).
95    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/// Canonical projection of a Lua package's `M.meta` block.
111///
112/// `name` is required (= hub-index inclusion gate). Other fields are
113/// optional and degrade UI / discoverability when absent, following the
114/// BP convention of Cargo / JSR / npm.
115#[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    /// Package type (`"runnable"` or `"library"`). Serialized as `"type"` on
129    /// the wire. `None` means the type was not determined (legacy entries).
130    #[serde(default, rename = "type")]
131    pub pkg_type: Option<PkgType>,
132    /// Records how `pkg_type` was determined. `None` for legacy entries that
133    /// pre-date provenance tracking (`#[serde(default)]` ensures backward
134    /// compatibility with existing `hub_index.json` consumers).
135    ///
136    /// Uses a lenient deserializer so that legacy JSON with `"type_source":
137    /// "explicit"` (a v0.40.0-only addition now removed) degrades to `None`
138    /// instead of erroring.
139    #[serde(default, deserialize_with = "deserialize_type_source_lenient")]
140    pub type_source: Option<TypeSource>,
141}
142
143/// Lenient deserializer for `PkgEntity.type_source`.
144///
145/// Converts unknown variant strings (e.g. legacy `"explicit"`) to `None`
146/// instead of returning a deserialization error. Known variants
147/// (`"auto_detected_runnable"` / `"auto_detected_library"`) are returned as
148/// `Some(TypeSource)`.
149fn 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    /// Parse `M.meta` + leading `---` docstring from an `init.lua`.
159    ///
160    /// Returns `None` when the file cannot be read, `M.meta` is absent, or
161    /// `M.meta.name` is empty. Callers treat `None` as "not a package" and
162    /// drop the directory silently from the hub index.
163    ///
164    /// The parser is **not** a full Lua evaluator:
165    ///
166    /// - Only flat key–value pairs inside `M.meta` are extracted.
167    /// - Nested tables (e.g. `tags = { ... }`) are skipped via brace-depth
168    ///   tracking; their keys are not reachable from here.
169    /// - Values must be string literals (`"..."`), optionally joined by `..`
170    ///   concatenation with whitespace between operators.
171    /// - Occurrences of `M.meta` inside single-line comments (`-- ...`)
172    ///   are ignored, so docstrings mentioning the key do not hijack the
173    ///   search.
174    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        // Type and provenance are set by VM eval (LUA_TYPE_AUTODETECT) at the
179        // call site (build_index). This function handles name/meta extraction
180        // and the silent-exclude gate only.
181        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
200/// Return `None` for empty strings, `Some(s)` otherwise. Kept inline with
201/// `parse_from_init_lua` so the "empty field = absent" projection rule is
202/// applied uniformly to every optional column.
203fn option_from_str(s: String) -> Option<String> {
204    if s.is_empty() {
205        None
206    } else {
207        Some(s)
208    }
209}
210
211/// Extract leading `---` doc-comment lines from an init.lua source. Blank
212/// lines within the block are tolerated; the first non-doc content line
213/// terminates the block.
214fn 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
229/// Intermediate result of parsing `M.meta = { ... }` from an `init.lua`.
230/// All fields are owned. `type` is not extracted here; type is determined
231/// by VM eval (`LUA_TYPE_AUTODETECT`) at the call site.
232struct ParsedMeta {
233    name: String,
234    version: String,
235    description: String,
236    category: String,
237    tags: Vec<String>,
238}
239
240/// Parse `M.meta = { ... }` out of `content`. Returns `None` if the block is
241/// missing, unparseable, or `name` is empty.
242fn parse_meta(content: &str) -> Option<ParsedMeta> {
243    let head = content;
244
245    // Find M.meta = { ... } block (with brace-depth tracking).
246    // Skip occurrences inside Lua line comments (`-- ...`) so that
247    // docstrings mentioning "M.meta" do not hijack the search.
248    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    // Track brace depth so nested tables do not terminate the block.
261    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        // Match: field = "value" [.. "value" ...] with word-boundary check.
281        // Walk through all occurrences of `field`, skipping matches inside
282        // longer identifiers (e.g. "short_description"). On the first valid
283        // occurrence, collect one or more `"..."` string literals joined by
284        // `..` concatenation operators.
285        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                        // Between the prior closing quote and this opening
304                        // quote, only whitespace and a single `..` operator
305                        // are allowed. Anything else (comma, another field,
306                        // etc.) ends the value.
307                        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
343/// Extract a string array from a nested table like `tags = { "a", "b" }`.
344/// Returns an empty Vec if the field is absent or has no string elements.
345fn 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        // Mirrors the alc_shapes case: an init.lua with no M.meta block at
574        // all. This is the **silent exclusion gate** — the caller drops
575        // these directories from the hub index without warning.
576        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        // A `M.meta` reference inside a `--` comment must not hijack the
658        // parser. The real block below it should still be found.
659        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        // Wire format contract: None is serialized as null; empty string
678        // deserializes as Some("") (not None). Keep these separable so the
679        // consumer can distinguish "field absent" from "field present but
680        // empty".
681        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        // Legacy hub_index.json entries may omit every optional field;
709        // they must deserialize as None (not error).
710        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    // ─── PkgType / pkg_type field tests ──────────────────────────
720
721    #[test]
722    fn parse_from_init_lua_pkg_type_is_none() {
723        // parse_from_init_lua no longer extracts type; pkg_type is always None.
724        // Type is determined by VM eval (LUA_TYPE_AUTODETECT) at the call site.
725        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        // Acceptance criterion 10: PkgType::Library survives a JSON round-trip.
754        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        // Acceptance criterion 11: JSON without "type" key → pkg_type = None
777        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    // ─── TypeSource / type_source provenance tests ───────────────
787
788    #[test]
789    fn serde_round_trip_with_type_source() {
790        // TypeSource::AutoDetectedLibrary JSON round-trip → wire string "auto_detected_library"
791        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        // Acceptance criterion: JSON without "type_source" key → type_source == None
815        // (backward compat for legacy hub_index.json entries)
816        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        // Risk 1: legacy hub_index.json with "type_source": "explicit" must not error.
829        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}