Skip to main content

assay/
discovery.rs

1//! Module discovery and search index builder.
2//!
3//! Discovers Assay modules from three sources (in priority order):
4//! 1. Project — `./modules/` relative to CWD
5//! 2. Global  — `$ASSAY_MODULES_PATH` or `~/.assay/modules/`
6//! 3. BuiltIn — embedded stdlib + hardcoded Rust builtins
7
8use crate::search::{SearchEngine, SearchResult};
9use include_dir::{Dir, include_dir};
10
11use crate::metadata::{self, ModuleMetadata};
12#[cfg(not(feature = "db"))]
13use crate::search::BM25Index;
14#[cfg(feature = "db")]
15use crate::search_fts5::FTS5Index;
16
17static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");
18
19/// Where a discovered module originates from.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ModuleSource {
22    /// Embedded in the binary via `include_dir!`
23    BuiltIn,
24    /// Found in `./modules/` relative to CWD
25    Project,
26    /// Found in `$ASSAY_MODULES_PATH` or `~/.assay/modules/`
27    Global,
28}
29
30impl ModuleSource {
31    pub fn label(&self) -> &'static str {
32        match self {
33            ModuleSource::BuiltIn => "builtin",
34            ModuleSource::Project => "project",
35            ModuleSource::Global => "global",
36        }
37    }
38}
39
40/// A module discovered during the discovery phase.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct DiscoveredModule {
44    pub module_name: String,
45    pub source: ModuleSource,
46    pub metadata: ModuleMetadata,
47    pub lua_source: String,
48}
49
50/// Hardcoded Rust builtins with their descriptions and search keywords.
51const BUILTINS: &[(&str, &str, &[&str], &str)] = &[
52    (
53        "http",
54        "HTTP client and server: get, post, put, patch, delete, serve",
55        &[
56            "http", "client", "server", "request", "response", "headers", "endpoint", "api",
57            "webhook", "rest",
58        ],
59        "core",
60    ),
61    (
62        "json",
63        "JSON serialization: parse and encode",
64        &[
65            "json",
66            "serialization",
67            "deserialize",
68            "stringify",
69            "parse",
70            "encode",
71            "format",
72        ],
73        "core",
74    ),
75    (
76        "yaml",
77        "YAML serialization: parse and encode",
78        &[
79            "yaml",
80            "serialization",
81            "deserialize",
82            "parse",
83            "encode",
84            "format",
85        ],
86        "core",
87    ),
88    (
89        "toml",
90        "TOML serialization: parse and encode",
91        &[
92            "toml",
93            "serialization",
94            "deserialize",
95            "parse",
96            "encode",
97            "configuration",
98        ],
99        "core",
100    ),
101    (
102        "fs",
103        "Filesystem: read and write files",
104        &["fs", "filesystem", "file", "read", "write", "io", "path"],
105        "core",
106    ),
107    (
108        "crypto",
109        "Cryptography: jwt_sign, hash, hmac, random",
110        &[
111            "crypto",
112            "jwt",
113            "signature",
114            "hash",
115            "hmac",
116            "encryption",
117            "random",
118            "security",
119            "password",
120            "signing",
121            "rsa",
122            "sha256",
123        ],
124        "core",
125    ),
126    (
127        "base64",
128        "Base64 encoding and decoding",
129        &["base64", "encoding", "decode", "encode", "binary"],
130        "core",
131    ),
132    (
133        "regex",
134        "Regular expressions: match, find, find_all, replace",
135        &[
136            "regex",
137            "pattern",
138            "match",
139            "find",
140            "replace",
141            "regular-expression",
142            "regexp",
143        ],
144        "core",
145    ),
146    (
147        "db",
148        "Database: connect, query, execute, close (Postgres, MySQL, SQLite)",
149        &[
150            "db",
151            "database",
152            "sql",
153            "postgres",
154            "mysql",
155            "sqlite",
156            "connection",
157            "query",
158            "execute",
159        ],
160        "data",
161    ),
162    (
163        "ws",
164        "WebSocket: connect, send, recv, close",
165        &[
166            "ws",
167            "websocket",
168            "connection",
169            "message",
170            "streaming",
171            "realtime",
172            "socket",
173        ],
174        "core",
175    ),
176    (
177        "template",
178        "Jinja2-compatible templates: render file or string",
179        &[
180            "template",
181            "jinja2",
182            "rendering",
183            "string-template",
184            "mustache",
185            "render",
186        ],
187        "core",
188    ),
189    (
190        "async",
191        "Async tasks: spawn, spawn_interval, await, cancel",
192        &[
193            "async",
194            "asynchronous",
195            "task",
196            "coroutine",
197            "concurrent",
198            "spawn",
199            "interval",
200        ],
201        "core",
202    ),
203    (
204        "assert",
205        "Assertions: eq, gt, lt, contains, not_nil, matches",
206        &[
207            "assert",
208            "assertion",
209            "test",
210            "validation",
211            "comparison",
212            "check",
213            "verify",
214        ],
215        "core",
216    ),
217    (
218        "log",
219        "Logging: info, warn, error",
220        &[
221            "log", "logging", "output", "debug", "error", "warning", "info", "trace",
222        ],
223        "core",
224    ),
225    (
226        "env",
227        "Environment variables: get",
228        &["env", "environment", "variable", "configuration", "config"],
229        "core",
230    ),
231    (
232        "sleep",
233        "Sleep for N seconds",
234        &["sleep", "delay", "pause", "wait", "time"],
235        "core",
236    ),
237    (
238        "time",
239        "Unix timestamp in seconds",
240        &["time", "timestamp", "unix", "epoch", "clock", "datetime"],
241        "core",
242    ),
243    (
244        "compress",
245        "Decompression: gunzip, unxz, unzstd. Pure binary in/out.",
246        &[
247            "compress",
248            "decompress",
249            "gunzip",
250            "gzip",
251            "xz",
252            "lzma",
253            "zstd",
254        ],
255        "core",
256    ),
257];
258
259/// Discover all modules: embedded stdlib + `./modules/` + `~/.assay/modules/` (or `$ASSAY_MODULES_PATH`).
260///
261/// Returns modules ordered by priority: Project first, then Global, then BuiltIn.
262/// Callers can deduplicate by name, keeping the highest-priority (first) occurrence.
263pub fn discover_modules() -> Vec<DiscoveredModule> {
264    let mut modules = Vec::new();
265
266    // Priority 1: Project modules (./modules/)
267    discover_filesystem_modules(
268        std::path::Path::new("./modules"),
269        ModuleSource::Project,
270        &mut modules,
271    );
272
273    // Priority 2: Global modules ($ASSAY_MODULES_PATH or ~/.assay/modules/)
274    let global_path = resolve_global_modules_path();
275    if let Some(path) = global_path {
276        discover_filesystem_modules(&path, ModuleSource::Global, &mut modules);
277    }
278
279    // Priority 3: Embedded stdlib .lua files
280    discover_embedded_stdlib(&mut modules);
281
282    // Priority 3 (continued): Hardcoded Rust builtins
283    discover_rust_builtins(&mut modules);
284
285    modules
286}
287
288/// Build a search index from discovered modules.
289///
290/// When feature `db` is enabled: uses `FTS5Index`.
291/// When feature `db` is disabled: uses `BM25Index`.
292pub fn build_index(modules: &[DiscoveredModule]) -> Box<dyn SearchEngine> {
293    #[cfg(feature = "db")]
294    {
295        let mut idx = FTS5Index::new();
296        for m in modules {
297            idx.add_document(
298                &m.module_name,
299                &[
300                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
301                    ("module_name", &m.module_name, 2.0),
302                    ("description", &m.metadata.description, 1.0),
303                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
304                ],
305            );
306        }
307        Box::new(idx)
308    }
309    #[cfg(not(feature = "db"))]
310    {
311        let mut idx = BM25Index::new();
312        for m in modules {
313            idx.add_document(
314                &m.module_name,
315                &[
316                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
317                    ("module_name", &m.module_name, 2.0),
318                    ("description", &m.metadata.description, 1.0),
319                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
320                ],
321            );
322        }
323        Box::new(idx)
324    }
325}
326
327/// Convenience: discover all modules, build index, search, return results.
328pub fn search_modules(query: &str, limit: usize) -> Vec<SearchResult> {
329    let modules = discover_modules();
330    let index = build_index(&modules);
331    index.search(query, limit)
332}
333
334/// Resolve the global modules directory path.
335///
336/// Checks `$ASSAY_MODULES_PATH` first, then falls back to `~/.assay/modules/`.
337/// Returns `None` if neither is available.
338fn resolve_global_modules_path() -> Option<std::path::PathBuf> {
339    if let Ok(custom) = std::env::var(crate::lua::MODULES_PATH_ENV) {
340        return Some(std::path::PathBuf::from(custom));
341    }
342    if let Ok(home) = std::env::var("HOME") {
343        return Some(std::path::Path::new(&home).join(".assay/modules"));
344    }
345    None
346}
347
348/// Discover `.lua` files from a filesystem directory.
349///
350/// Silently skips if the directory does not exist.
351fn discover_filesystem_modules(
352    dir: &std::path::Path,
353    source: ModuleSource,
354    modules: &mut Vec<DiscoveredModule>,
355) {
356    let entries = match std::fs::read_dir(dir) {
357        Ok(entries) => entries,
358        Err(_) => return, // Directory doesn't exist or can't be read — skip silently
359    };
360
361    for entry in entries.flatten() {
362        let path = entry.path();
363        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
364            continue;
365        }
366
367        let lua_source = match std::fs::read_to_string(&path) {
368            Ok(s) => s,
369            Err(_) => continue,
370        };
371
372        let stem = path
373            .file_stem()
374            .and_then(|s| s.to_str())
375            .unwrap_or_default();
376        let module_name = format!("assay.{stem}");
377        let meta = metadata::parse_metadata(&lua_source);
378
379        modules.push(DiscoveredModule {
380            module_name,
381            source: source.clone(),
382            metadata: meta,
383            lua_source,
384        });
385    }
386}
387
388/// Discover embedded stdlib `.lua` files from `include_dir!`.
389///
390/// Recurses into subdirectories so nested namespaces like
391/// `engine/vault.lua` register as `assay.engine.vault`. The path-to-
392/// module-name mapping replaces the OS separator with `.`. Both
393/// `engine.lua` (the facade) and `engine/vault.lua` (a submodule) get
394/// registered as separate `assay.engine` and `assay.engine.vault`
395/// entries respectively, matching what `require()` already resolves.
396fn discover_embedded_stdlib(modules: &mut Vec<DiscoveredModule>) {
397    fn walk(dir: &include_dir::Dir<'_>, modules: &mut Vec<DiscoveredModule>) {
398        for file in dir.files() {
399            let path = file.path();
400            if path.extension().and_then(|e| e.to_str()) != Some("lua") {
401                continue;
402            }
403            let Some(lua_source) = file.contents_utf8() else {
404                continue;
405            };
406            // Build dotted path from "stdlib"-relative components,
407            // dropping the trailing `.lua`.
408            let segments: Vec<&str> = path.iter().filter_map(|c| c.to_str()).collect();
409            if segments.is_empty() {
410                continue;
411            }
412            let mut joined = segments.join(".");
413            if joined.ends_with(".lua") {
414                joined.truncate(joined.len() - 4);
415            }
416            let module_name = format!("assay.{joined}");
417            let meta = metadata::parse_metadata(lua_source);
418            modules.push(DiscoveredModule {
419                module_name,
420                source: ModuleSource::BuiltIn,
421                metadata: meta,
422                lua_source: lua_source.to_string(),
423            });
424        }
425        for sub in dir.dirs() {
426            walk(sub, modules);
427        }
428    }
429    walk(&STDLIB_DIR, modules);
430}
431
432/// Add hardcoded Rust builtins (not Lua files) to the module list.
433fn discover_rust_builtins(modules: &mut Vec<DiscoveredModule>) {
434    for &(name, description, kw, category) in BUILTINS {
435        modules.push(DiscoveredModule {
436            module_name: name.to_string(),
437            source: ModuleSource::BuiltIn,
438            lua_source: String::new(),
439            metadata: ModuleMetadata {
440                module_name: name.to_string(),
441                description: description.to_string(),
442                keywords: kw.iter().map(|k| k.to_string()).collect(),
443                category: Some(category.to_string()),
444                ..Default::default()
445            },
446        });
447    }
448}