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