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