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
217/// Discover all modules: embedded stdlib + `./modules/` + `~/.assay/modules/` (or `$ASSAY_MODULES_PATH`).
218///
219/// Returns modules ordered by priority: Project first, then Global, then BuiltIn.
220/// Callers can deduplicate by name, keeping the highest-priority (first) occurrence.
221pub fn discover_modules() -> Vec<DiscoveredModule> {
222    let mut modules = Vec::new();
223
224    // Priority 1: Project modules (./modules/)
225    discover_filesystem_modules(
226        std::path::Path::new("./modules"),
227        ModuleSource::Project,
228        &mut modules,
229    );
230
231    // Priority 2: Global modules ($ASSAY_MODULES_PATH or ~/.assay/modules/)
232    let global_path = resolve_global_modules_path();
233    if let Some(path) = global_path {
234        discover_filesystem_modules(&path, ModuleSource::Global, &mut modules);
235    }
236
237    // Priority 3: Embedded stdlib .lua files
238    discover_embedded_stdlib(&mut modules);
239
240    // Priority 3 (continued): Hardcoded Rust builtins
241    discover_rust_builtins(&mut modules);
242
243    modules
244}
245
246/// Build a search index from discovered modules.
247///
248/// When feature `db` is enabled: uses `FTS5Index`.
249/// When feature `db` is disabled: uses `BM25Index`.
250pub fn build_index(modules: &[DiscoveredModule]) -> Box<dyn SearchEngine> {
251    #[cfg(feature = "db")]
252    {
253        let mut idx = FTS5Index::new();
254        for m in modules {
255            idx.add_document(
256                &m.module_name,
257                &[
258                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
259                    ("module_name", &m.module_name, 2.0),
260                    ("description", &m.metadata.description, 1.0),
261                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
262                ],
263            );
264        }
265        Box::new(idx)
266    }
267    #[cfg(not(feature = "db"))]
268    {
269        let mut idx = BM25Index::new();
270        for m in modules {
271            idx.add_document(
272                &m.module_name,
273                &[
274                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
275                    ("module_name", &m.module_name, 2.0),
276                    ("description", &m.metadata.description, 1.0),
277                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
278                ],
279            );
280        }
281        Box::new(idx)
282    }
283}
284
285/// Convenience: discover all modules, build index, search, return results.
286pub fn search_modules(query: &str, limit: usize) -> Vec<SearchResult> {
287    let modules = discover_modules();
288    let index = build_index(&modules);
289    index.search(query, limit)
290}
291
292/// Resolve the global modules directory path.
293///
294/// Checks `$ASSAY_MODULES_PATH` first, then falls back to `~/.assay/modules/`.
295/// Returns `None` if neither is available.
296fn resolve_global_modules_path() -> Option<std::path::PathBuf> {
297    if let Ok(custom) = std::env::var(crate::lua::MODULES_PATH_ENV) {
298        return Some(std::path::PathBuf::from(custom));
299    }
300    if let Ok(home) = std::env::var("HOME") {
301        return Some(std::path::Path::new(&home).join(".assay/modules"));
302    }
303    None
304}
305
306/// Discover `.lua` files from a filesystem directory.
307///
308/// Silently skips if the directory does not exist.
309fn discover_filesystem_modules(
310    dir: &std::path::Path,
311    source: ModuleSource,
312    modules: &mut Vec<DiscoveredModule>,
313) {
314    let entries = match std::fs::read_dir(dir) {
315        Ok(entries) => entries,
316        Err(_) => return, // Directory doesn't exist or can't be read — skip silently
317    };
318
319    for entry in entries.flatten() {
320        let path = entry.path();
321        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
322            continue;
323        }
324
325        let lua_source = match std::fs::read_to_string(&path) {
326            Ok(s) => s,
327            Err(_) => continue,
328        };
329
330        let stem = path
331            .file_stem()
332            .and_then(|s| s.to_str())
333            .unwrap_or_default();
334        let module_name = format!("assay.{stem}");
335        let meta = metadata::parse_metadata(&lua_source);
336
337        modules.push(DiscoveredModule {
338            module_name,
339            source: source.clone(),
340            metadata: meta,
341            lua_source,
342        });
343    }
344}
345
346/// Discover embedded stdlib `.lua` files from `include_dir!`.
347fn discover_embedded_stdlib(modules: &mut Vec<DiscoveredModule>) {
348    for file in STDLIB_DIR.files() {
349        let path = file.path();
350        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
351            continue;
352        }
353
354        let lua_source = match file.contents_utf8() {
355            Some(s) => s,
356            None => continue,
357        };
358
359        let stem = path
360            .file_stem()
361            .and_then(|s| s.to_str())
362            .unwrap_or_default();
363        let module_name = format!("assay.{stem}");
364        let meta = metadata::parse_metadata(lua_source);
365
366        modules.push(DiscoveredModule {
367            module_name,
368            source: ModuleSource::BuiltIn,
369            metadata: meta,
370            lua_source: lua_source.to_string(),
371        });
372    }
373}
374
375/// Add hardcoded Rust builtins (not Lua files) to the module list.
376fn discover_rust_builtins(modules: &mut Vec<DiscoveredModule>) {
377    for &(name, description, kw) in BUILTINS {
378        modules.push(DiscoveredModule {
379            module_name: name.to_string(),
380            source: ModuleSource::BuiltIn,
381            lua_source: String::new(),
382            metadata: ModuleMetadata {
383                module_name: name.to_string(),
384                description: description.to_string(),
385                keywords: kw.iter().map(|k| k.to_string()).collect(),
386                ..Default::default()
387            },
388        });
389    }
390}