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 include_dir::{include_dir, Dir};
9use crate::search::{SearchEngine, SearchResult};
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.
40const BUILTINS: &[(&str, &str)] = &[
41    (
42        "http",
43        "HTTP client and server: get, post, put, patch, delete, serve",
44    ),
45    ("json", "JSON serialization: parse and encode"),
46    ("yaml", "YAML serialization: parse and encode"),
47    ("toml", "TOML serialization: parse and encode"),
48    ("fs", "Filesystem: read and write files"),
49    ("crypto", "Cryptography: jwt_sign, hash, hmac, random"),
50    ("base64", "Base64 encoding and decoding"),
51    (
52        "regex",
53        "Regular expressions: match, find, find_all, replace",
54    ),
55    (
56        "db",
57        "Database: connect, query, execute, close (Postgres, MySQL, SQLite)",
58    ),
59    ("ws", "WebSocket: connect, send, recv, close"),
60    (
61        "template",
62        "Jinja2-compatible templates: render file or string",
63    ),
64    ("async", "Async tasks: spawn, spawn_interval, await, cancel"),
65    (
66        "assert",
67        "Assertions: eq, gt, lt, contains, not_nil, matches",
68    ),
69    ("log", "Logging: info, warn, error"),
70    ("env", "Environment variables: get"),
71    ("sleep", "Sleep for N seconds"),
72    ("time", "Unix timestamp in seconds"),
73];
74
75/// Discover all modules: embedded stdlib + `./modules/` + `~/.assay/modules/` (or `$ASSAY_MODULES_PATH`).
76///
77/// Returns modules ordered by priority: Project first, then Global, then BuiltIn.
78/// Callers can deduplicate by name, keeping the highest-priority (first) occurrence.
79pub fn discover_modules() -> Vec<DiscoveredModule> {
80    let mut modules = Vec::new();
81
82    // Priority 1: Project modules (./modules/)
83    discover_filesystem_modules(
84        std::path::Path::new("./modules"),
85        ModuleSource::Project,
86        &mut modules,
87    );
88
89    // Priority 2: Global modules ($ASSAY_MODULES_PATH or ~/.assay/modules/)
90    let global_path = resolve_global_modules_path();
91    if let Some(path) = global_path {
92        discover_filesystem_modules(&path, ModuleSource::Global, &mut modules);
93    }
94
95    // Priority 3: Embedded stdlib .lua files
96    discover_embedded_stdlib(&mut modules);
97
98    // Priority 3 (continued): Hardcoded Rust builtins
99    discover_rust_builtins(&mut modules);
100
101    modules
102}
103
104/// Build a search index from discovered modules.
105///
106/// When feature `db` is enabled: uses `FTS5Index`.
107/// When feature `db` is disabled: uses `BM25Index`.
108pub fn build_index(modules: &[DiscoveredModule]) -> Box<dyn SearchEngine> {
109    #[cfg(feature = "db")]
110    {
111        let mut idx = FTS5Index::new();
112        for m in modules {
113            idx.add_document(
114                &m.module_name,
115                &[
116                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
117                    ("module_name", &m.module_name, 2.0),
118                    ("description", &m.metadata.description, 1.0),
119                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
120                ],
121            );
122        }
123        Box::new(idx)
124    }
125    #[cfg(not(feature = "db"))]
126    {
127        let mut idx = BM25Index::new();
128        for m in modules {
129            idx.add_document(
130                &m.module_name,
131                &[
132                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
133                    ("module_name", &m.module_name, 2.0),
134                    ("description", &m.metadata.description, 1.0),
135                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
136                ],
137            );
138        }
139        Box::new(idx)
140    }
141}
142
143/// Convenience: discover all modules, build index, search, return results.
144pub fn search_modules(query: &str, limit: usize) -> Vec<SearchResult> {
145    let modules = discover_modules();
146    let index = build_index(&modules);
147    index.search(query, limit)
148}
149
150/// Resolve the global modules directory path.
151///
152/// Checks `$ASSAY_MODULES_PATH` first, then falls back to `~/.assay/modules/`.
153/// Returns `None` if neither is available.
154fn resolve_global_modules_path() -> Option<std::path::PathBuf> {
155    if let Ok(custom) = std::env::var(crate::lua::MODULES_PATH_ENV) {
156        return Some(std::path::PathBuf::from(custom));
157    }
158    if let Ok(home) = std::env::var("HOME") {
159        return Some(std::path::Path::new(&home).join(".assay/modules"));
160    }
161    None
162}
163
164/// Discover `.lua` files from a filesystem directory.
165///
166/// Silently skips if the directory does not exist.
167fn discover_filesystem_modules(
168    dir: &std::path::Path,
169    source: ModuleSource,
170    modules: &mut Vec<DiscoveredModule>,
171) {
172    let entries = match std::fs::read_dir(dir) {
173        Ok(entries) => entries,
174        Err(_) => return, // Directory doesn't exist or can't be read — skip silently
175    };
176
177    for entry in entries.flatten() {
178        let path = entry.path();
179        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
180            continue;
181        }
182
183        let lua_source = match std::fs::read_to_string(&path) {
184            Ok(s) => s,
185            Err(_) => continue,
186        };
187
188        let stem = path
189            .file_stem()
190            .and_then(|s| s.to_str())
191            .unwrap_or_default();
192        let module_name = format!("assay.{stem}");
193        let meta = metadata::parse_metadata(&lua_source);
194
195        modules.push(DiscoveredModule {
196            module_name,
197            source: source.clone(),
198            metadata: meta,
199            lua_source,
200        });
201    }
202}
203
204/// Discover embedded stdlib `.lua` files from `include_dir!`.
205fn discover_embedded_stdlib(modules: &mut Vec<DiscoveredModule>) {
206    for file in STDLIB_DIR.files() {
207        let path = file.path();
208        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
209            continue;
210        }
211
212        let lua_source = match file.contents_utf8() {
213            Some(s) => s,
214            None => continue,
215        };
216
217        let stem = path
218            .file_stem()
219            .and_then(|s| s.to_str())
220            .unwrap_or_default();
221        let module_name = format!("assay.{stem}");
222        let meta = metadata::parse_metadata(lua_source);
223
224        modules.push(DiscoveredModule {
225            module_name,
226            source: ModuleSource::BuiltIn,
227            metadata: meta,
228            lua_source: lua_source.to_string(),
229        });
230    }
231}
232
233/// Add hardcoded Rust builtins (not Lua files) to the module list.
234fn discover_rust_builtins(modules: &mut Vec<DiscoveredModule>) {
235    for &(name, description) in BUILTINS {
236        modules.push(DiscoveredModule {
237            module_name: name.to_string(),
238            source: ModuleSource::BuiltIn,
239            lua_source: String::new(),
240            metadata: ModuleMetadata {
241                module_name: name.to_string(),
242                description: description.to_string(),
243                keywords: vec![name.to_string()],
244                ..Default::default()
245            },
246        });
247    }
248}