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), while the offline
47/// `parse_from_init_lua` path uses `detect_has_run` (text-scan mirror).
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_doctor`, `alc_pkg_list`) can distinguish
82/// packages that were auto-detected as libraries from packages that
83/// explicitly declared their type — and emit a targeted suggestion in the
84/// former case.
85///
86/// Wire format: `"explicit"` / `"auto_detected_runnable"` /
87/// `"auto_detected_library"` (snake_case via `#[serde(rename_all = "snake_case")]`).
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum TypeSource {
91    /// `M.meta.type` was present in the package source — type is authoritative.
92    Explicit,
93    /// `M.meta.type` was absent; the package was classified as `runnable`
94    /// because `M.run` is defined (offline Rust path or Lua VM detection).
95    AutoDetectedRunnable,
96    /// `M.meta.type` was absent; the package was classified as `library`
97    /// because no `M.run` was found. Consider adding `M.meta.type = "library"`
98    /// to make this explicit.
99    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/// Canonical projection of a Lua package's `M.meta` block.
116///
117/// `name` is required (= hub-index inclusion gate). Other fields are
118/// optional and degrade UI / discoverability when absent, following the
119/// BP convention of Cargo / JSR / npm.
120#[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    /// Package type (`"runnable"` or `"library"`). Serialized as `"type"` on
134    /// the wire. `None` means the type was not determined (legacy entries).
135    #[serde(default, rename = "type")]
136    pub pkg_type: Option<PkgType>,
137    /// Records how `pkg_type` was determined. `None` for legacy entries that
138    /// pre-date provenance tracking (`#[serde(default)]` ensures backward
139    /// compatibility with existing `hub_index.json` consumers).
140    #[serde(default)]
141    pub type_source: Option<TypeSource>,
142}
143
144impl PkgEntity {
145    /// Parse `M.meta` + leading `---` docstring from an `init.lua`.
146    ///
147    /// Returns `None` when the file cannot be read, `M.meta` is absent, or
148    /// `M.meta.name` is empty. Callers treat `None` as "not a package" and
149    /// drop the directory silently from the hub index.
150    ///
151    /// The parser is **not** a full Lua evaluator:
152    ///
153    /// - Only flat key–value pairs inside `M.meta` are extracted.
154    /// - Nested tables (e.g. `tags = { ... }`) are skipped via brace-depth
155    ///   tracking; their keys are not reachable from here.
156    /// - Values must be string literals (`"..."`), optionally joined by `..`
157    ///   concatenation with whitespace between operators.
158    /// - Occurrences of `M.meta` inside single-line comments (`-- ...`)
159    ///   are ignored, so docstrings mentioning the key do not hijack the
160    ///   search.
161    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        // Resolve type and provenance: explicit M.meta.type wins; otherwise
166        // fall back to Rust text-scan (build_index / offline batch path) and
167        // record that the type was auto-detected.
168        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
201/// Return `None` for empty strings, `Some(s)` otherwise. Kept inline with
202/// `parse_from_init_lua` so the "empty field = absent" projection rule is
203/// applied uniformly to every optional column.
204fn option_from_str(s: String) -> Option<String> {
205    if s.is_empty() {
206        None
207    } else {
208        Some(s)
209    }
210}
211
212/// Extract leading `---` doc-comment lines from an init.lua source. Blank
213/// lines within the block are tolerated; the first non-doc content line
214/// terminates the block.
215fn 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
230/// Intermediate result of parsing `M.meta = { ... }` from an `init.lua`.
231/// All fields are owned. `pkg_type` is `None` when the `type` key is absent
232/// or has an unrecognized value (caller applies auto-detect fallback).
233struct ParsedMeta {
234    name: String,
235    version: String,
236    description: String,
237    category: String,
238    tags: Vec<String>,
239    pkg_type: Option<PkgType>,
240}
241
242/// Detect whether the init.lua source declares `M.run`.
243///
244/// Used by `parse_from_init_lua` as the offline (non-VM) fallback for
245/// `build_index` when `M.meta.type` is absent. The Lua VM path (`pkg_list`,
246/// `resolve_pkg_type_lua`) uses `type(pkg.run) == "function"` at runtime and
247/// is the canonical source; this function is the Rust mirror for offline batch.
248///
249/// Comment exclusion: for each line, only the portion before the first `--`
250/// is considered, so `-- M.run = ...` or `-- function M.run(ctx)` do not
251/// trigger a match.
252fn detect_has_run(content: &str) -> bool {
253    for line in content.lines() {
254        // Strip inline comment suffix.
255        let effective = line.split("--").next().unwrap_or(line);
256        if effective.contains("M.run") {
257            return true;
258        }
259    }
260    false
261}
262
263/// Parse `M.meta = { ... }` out of `content`. Returns `None` if the block is
264/// missing, unparseable, or `name` is empty.
265fn parse_meta(content: &str) -> Option<ParsedMeta> {
266    let head = content;
267
268    // Find M.meta = { ... } block (with brace-depth tracking).
269    // Skip occurrences inside Lua line comments (`-- ...`) so that
270    // docstrings mentioning "M.meta" do not hijack the search.
271    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    // Track brace depth so nested tables do not terminate the block.
284    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        // Match: field = "value" [.. "value" ...] with word-boundary check.
304        // Walk through all occurrences of `field`, skipping matches inside
305        // longer identifiers (e.g. "short_description"). On the first valid
306        // occurrence, collect one or more `"..."` string literals joined by
307        // `..` concatenation operators.
308        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                        // Between the prior closing quote and this opening
327                        // quote, only whitespace and a single `..` operator
328                        // are allowed. Anything else (comma, another field,
329                        // etc.) ends the value.
330                        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    // Parse `type` field; unrecognized values fall back to None (auto-detect).
358    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
376/// Extract a string array from a nested table like `tags = { "a", "b" }`.
377/// Returns an empty Vec if the field is absent or has no string elements.
378fn 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        // Mirrors the alc_shapes case: an init.lua with no M.meta block at
607        // all. This is the **silent exclusion gate** — the caller drops
608        // these directories from the hub index without warning.
609        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        // A `M.meta` reference inside a `--` comment must not hijack the
691        // parser. The real block below it should still be found.
692        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        // Wire format contract: None is serialized as null; empty string
711        // deserializes as Some("") (not None). Keep these separable so the
712        // consumer can distinguish "field absent" from "field present but
713        // empty".
714        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        // Legacy hub_index.json entries may omit every optional field;
742        // they must deserialize as None (not error).
743        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    // ─── PkgType / pkg_type field tests ──────────────────────────
753
754    #[test]
755    fn parse_type_from_meta() {
756        // Acceptance criterion 5: M.meta.type = "library" → PkgType::Library
757        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        // Acceptance criterion 6: M.meta.type = "runnable" → PkgType::Runnable
779        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        // Acceptance criterion 7: no M.meta.type, but M.run is present → Runnable
800        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        // Acceptance criterion 8: no M.meta.type, no M.run → Library
826        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        // Acceptance criterion 9: M.run in a comment must not trigger detection.
849        assert!(
850            !detect_has_run("-- M.run = function(ctx) end\nlocal M = {}\n"),
851            "commented-out M.run should not be detected"
852        );
853        // But a real M.run assignment on the same line after a comment is still
854        // in the non-comment portion before '--', so it IS detected.
855        assert!(
856            detect_has_run("local M = {}\nM.run = function(ctx) end\n"),
857            "real M.run should be detected"
858        );
859        // Edge case: M.run after inline comment on same line — not detected
860        // because the effective portion before '--' does not contain M.run.
861        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        // Acceptance criterion 10: PkgType::Library survives a JSON round-trip.
870        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        // Acceptance criterion 11: JSON without "type" key → pkg_type = None
893        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    // ─── TypeSource / type_source provenance tests ───────────────
903
904    #[test]
905    fn parse_explicit_type_sets_source_explicit() {
906        // Acceptance criterion: M.meta.type = "library" explicit → type_source == Explicit
907        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        // Acceptance criterion: type absent + M.run present → AutoDetectedRunnable
933        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        // Acceptance criterion: type absent + no M.run → AutoDetectedLibrary
961        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        // Acceptance criterion: TypeSource::AutoDetectedLibrary JSON round-trip
989        // → wire string "auto_detected_library"
990        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        // Acceptance criterion: JSON without "type_source" key → type_source == None
1018        // (backward compat for legacy hub_index.json entries)
1019        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}