Skip to main content

amql_engine/store/
mod.rs

1//! Load, index, and query `.aqm` annotation sidecar files.
2//!
3//! The annotation store is a pure in-memory index of parsed annotation data.
4//! It never parses source code — only AQL sidecar files.
5
6#[cfg(feature = "fs")]
7mod glob;
8mod parse;
9
10#[cfg(feature = "fs")]
11pub use glob::{glob_annotation_files, glob_aql_files};
12
13use crate::error::AqlError;
14use crate::matcher::{self, Matchable};
15use crate::selector::parse_selector;
16use crate::sidecar::SidecarLocator;
17use crate::types::{AttrName, Binding, ProjectRoot, RelativePath, Scope, TagName};
18use rustc_hash::FxHashMap;
19use serde::Serialize;
20use serde_json::Value as JsonValue;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::SystemTime;
24
25#[cfg(feature = "fs")]
26use rayon::prelude::*;
27
28/// A single annotation entry from an `.aqm` file.
29#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
30#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
31#[cfg_attr(feature = "ts", ts(export))]
32#[cfg_attr(feature = "flow", flow(export))]
33#[derive(Debug, Clone, Serialize)]
34pub struct Annotation {
35    /// Annotation tag (e.g. "controller", "react-hook").
36    pub tag: TagName,
37    /// User-defined attributes from the element's attributes.
38    #[cfg_attr(
39        feature = "ts",
40        ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
41    )]
42    #[cfg_attr(
43        feature = "flow",
44        flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
45    )]
46    pub attrs: FxHashMap<AttrName, JsonValue>,
47    /// Binding key for joining annotations to code elements.
48    /// From the `bind` attribute on the element.
49    pub binding: Binding,
50    /// Relative path of the source file this annotation targets.
51    pub file: RelativePath,
52    /// Nested child annotations.
53    pub children: Vec<Annotation>,
54}
55
56/// Input format for loading annotations: a file path and its XML content.
57#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
59#[cfg_attr(feature = "ts", ts(export))]
60#[cfg_attr(feature = "flow", flow(export))]
61#[derive(Debug, Clone, serde::Deserialize, Serialize)]
62pub struct FileEntry {
63    /// Relative file path.
64    pub file: RelativePath,
65    /// AQL XML content.
66    pub xml: String,
67}
68
69/// Internal storage of file annotations with mtime for cache invalidation.
70struct CachedFileEntry {
71    annotations: Vec<Annotation>,
72    #[cfg_attr(not(feature = "fs"), allow(dead_code))]
73    mtime: SystemTime,
74    #[cfg_attr(not(feature = "fs"), allow(dead_code))]
75    sidecar_path: PathBuf,
76}
77
78/// In-memory index of annotation sidecar files.
79pub struct AnnotationStore {
80    index: FxHashMap<RelativePath, CachedFileEntry>,
81    #[cfg_attr(not(feature = "fs"), allow(dead_code))]
82    project_root: ProjectRoot,
83    locator: Arc<dyn SidecarLocator>,
84}
85
86impl AnnotationStore {
87    /// Create an empty store rooted at the given project directory.
88    /// Uses colocated sidecar strategy by default.
89    #[cfg(feature = "fs")]
90    pub fn new(project_root: &Path) -> Self {
91        Self {
92            index: FxHashMap::default(),
93            project_root: ProjectRoot::from(project_root),
94            locator: Arc::new(crate::sidecar::ColocatedLocator),
95        }
96    }
97
98    /// Create an empty in-memory store (no filesystem access).
99    #[cfg(not(feature = "fs"))]
100    pub fn new(project_root: &Path) -> Self {
101        Self {
102            index: FxHashMap::default(),
103            project_root: ProjectRoot::from(project_root),
104            locator: Arc::new(crate::sidecar::InMemoryLocator),
105        }
106    }
107
108    /// Create an empty store with a custom sidecar locator strategy.
109    pub fn with_locator(project_root: &Path, locator: Arc<dyn SidecarLocator>) -> Self {
110        Self {
111            index: FxHashMap::default(),
112            project_root: ProjectRoot::from(project_root),
113            locator,
114        }
115    }
116
117    /// Return the sidecar locator used by this store.
118    pub fn locator(&self) -> &dyn SidecarLocator {
119        &*self.locator
120    }
121
122    /// Discover and load all sidecar files using the configured locator.
123    /// Returns parse errors for files that failed to load.
124    #[cfg(feature = "fs")]
125    pub fn load_all_from_locator(&mut self) -> Vec<String> {
126        let pairs = self.locator.discover(&self.project_root);
127        let results: Vec<_> = pairs
128            .par_iter()
129            .filter_map(|(sidecar_path, rel_source)| {
130                match (
131                    std::fs::metadata(sidecar_path),
132                    std::fs::read_to_string(sidecar_path),
133                ) {
134                    (Ok(meta), Ok(raw)) => {
135                        let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
136                        Some((
137                            sidecar_path.clone(),
138                            rel_source.clone(),
139                            mtime,
140                            parse::parse_sidecar(&raw, rel_source),
141                        ))
142                    }
143                    _ => None,
144                }
145            })
146            .collect();
147
148        let mut errors = Vec::new();
149        for (sidecar_path, rel_source, mtime, parse_result) in results {
150            match parse_result {
151                Ok(annotations) => {
152                    self.index.insert(
153                        rel_source,
154                        CachedFileEntry {
155                            annotations,
156                            mtime,
157                            sidecar_path,
158                        },
159                    );
160                }
161                Err(e) => errors.push(e),
162            }
163        }
164        errors
165    }
166
167    /// Load from pre-resolved sidecar paths (backward-compatible).
168    /// Returns parse errors for files that failed to load.
169    #[cfg(feature = "fs")]
170    pub fn load_all(&mut self, sidecar_paths: &[PathBuf]) -> Vec<String> {
171        let project_root = &self.project_root;
172        let results: Vec<_> = sidecar_paths
173            .par_iter()
174            .filter_map(|path| {
175                let stem = path.file_stem()?.to_string_lossy().to_string();
176                let parent = path.parent()?;
177                let source_path = parent.join(&stem);
178                let rel_source = crate::paths::relative(project_root, &source_path);
179
180                match (std::fs::metadata(path), std::fs::read_to_string(path)) {
181                    (Ok(meta), Ok(raw)) => {
182                        let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
183                        let parsed = parse::parse_sidecar(&raw, &rel_source);
184                        Some((path.to_path_buf(), rel_source, mtime, parsed))
185                    }
186                    _ => None,
187                }
188            })
189            .collect();
190
191        let mut errors = Vec::new();
192        for (sidecar_path, rel_source, mtime, parse_result) in results {
193            match parse_result {
194                Ok(annotations) => {
195                    self.index.insert(
196                        rel_source,
197                        CachedFileEntry {
198                            annotations,
199                            mtime,
200                            sidecar_path,
201                        },
202                    );
203                }
204                Err(e) => errors.push(e),
205            }
206        }
207        errors
208    }
209
210    /// Load or reload a single sidecar file.
211    /// Returns `Err` if the sidecar exists but fails to parse.
212    /// Returns `Ok(())` if loaded successfully or the file was removed.
213    #[cfg(feature = "fs")]
214    pub fn load_file(&mut self, sidecar_path: &Path) -> Result<(), String> {
215        let stem = sidecar_path
216            .file_stem()
217            .map(|s| s.to_string_lossy().to_string())
218            .unwrap_or_default();
219        let parent = sidecar_path.parent().unwrap_or(Path::new(""));
220        let source_path = parent.join(&stem);
221
222        let rel_source = crate::paths::relative(&self.project_root, &source_path);
223
224        match (
225            std::fs::metadata(sidecar_path),
226            std::fs::read_to_string(sidecar_path),
227        ) {
228            (Ok(meta), Ok(raw)) => {
229                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
230                let annotations = match parse::parse_sidecar(&raw, &rel_source) {
231                    Ok(a) => a,
232                    Err(e) => {
233                        self.index.remove(&*rel_source);
234                        return Err(e);
235                    }
236                };
237                self.index.insert(
238                    rel_source,
239                    CachedFileEntry {
240                        annotations,
241                        mtime,
242                        sidecar_path: sidecar_path.to_path_buf(),
243                    },
244                );
245                Ok(())
246            }
247            _ => {
248                self.index.remove(&*rel_source);
249                Ok(())
250            }
251        }
252    }
253
254    /// Check mtime and reload if the sidecar changed.
255    /// Entries with no sidecar path (extractor-only or load_xml) are skipped.
256    #[cfg(feature = "fs")]
257    pub fn refresh_if_stale(&mut self, rel_source: &RelativePath) -> bool {
258        let entry = match self.index.get(rel_source) {
259            Some(e) => e,
260            None => return false,
261        };
262
263        let sidecar_path = entry.sidecar_path.clone();
264
265        // Extractor-only or load_xml entries have no sidecar file to check
266        if sidecar_path.as_os_str().is_empty() {
267            return false;
268        }
269
270        match std::fs::metadata(&sidecar_path) {
271            Ok(meta) => {
272                let new_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
273                if new_mtime > entry.mtime {
274                    // Parse errors on refresh silently remove the entry
275                    let _ = self.load_file(&sidecar_path);
276                    return true;
277                }
278            }
279            Err(_) => {
280                self.index.remove(rel_source);
281                return true;
282            }
283        }
284        false
285    }
286
287    /// Check whether a file's sidecar is stale (mtime changed on disk).
288    ///
289    /// Returns `true` if the sidecar file on disk has a newer mtime than
290    /// the cached entry, or if the sidecar file no longer exists.
291    /// Returns `false` for extractor-only entries (no sidecar path).
292    #[cfg(feature = "fs")]
293    pub fn is_stale(&self, rel_source: &RelativePath) -> bool {
294        let entry = match self.index.get(rel_source) {
295            Some(e) => e,
296            None => return false,
297        };
298        if entry.sidecar_path.as_os_str().is_empty() {
299            return false;
300        }
301        match std::fs::metadata(&entry.sidecar_path) {
302            Ok(meta) => {
303                let disk_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
304                disk_mtime > entry.mtime
305            }
306            Err(_) => true,
307        }
308    }
309
310    /// Get cached code element tree for a file.
311    ///
312    /// Accepts a source path (`src/api.ts`) or sidecar path (`src/api.aqm`).
313    /// Falls back to stem-prefix matching when an exact key is not found.
314    pub fn get_file_annotations(&self, rel_source: &str) -> Vec<&Annotation> {
315        // Exact match first
316        if let Some(entry) = self.index.get(rel_source) {
317            return entry.annotations.iter().collect();
318        }
319
320        // Strip .aqm or source extension to get the stem for matching
321        let stem = rel_source
322            .strip_suffix(".aqm")
323            .or_else(|| rel_source.rfind('.').map(|dot| &rel_source[..dot]))
324            .unwrap_or(rel_source);
325
326        // Find the first key whose stem matches
327        self.index
328            .iter()
329            .find(|(key, _)| {
330                let key_str: &str = key.as_ref();
331                let key_stem = match key_str.rfind('.') {
332                    Some(dot) => &key_str[..dot],
333                    None => key_str,
334                };
335                key_stem == stem
336            })
337            .map(|(_, entry)| entry.annotations.iter().collect())
338            .unwrap_or_default()
339    }
340
341    /// Get all annotations across all files.
342    pub fn get_all_annotations(&self) -> Vec<&Annotation> {
343        self.index
344            .values()
345            .flat_map(|e| e.annotations.iter())
346            .collect()
347    }
348
349    /// Return annotations from files whose relative path starts with `scope`.
350    pub fn get_scope_annotations(&self, scope: &Scope) -> Vec<&Annotation> {
351        self.index
352            .iter()
353            .filter(|(key, _)| {
354                let k: &str = key.as_ref();
355                k.starts_with(&**scope)
356            })
357            .flat_map(|(_, e)| e.annotations.iter())
358            .collect()
359    }
360
361    /// Query annotations by selector string.
362    /// If `file` is provided, only search that file's annotations.
363    /// If `scope` is provided, only search annotations under that directory prefix.
364    /// Supports combinator selectors for nested annotations (e.g. `parser > helper`).
365    pub fn select(
366        &self,
367        selector_str: &str,
368        file: Option<&str>,
369        scope: Option<&str>,
370        opts: Option<&crate::query_options::QueryOptions>,
371    ) -> Result<Vec<Annotation>, AqlError> {
372        let selector = parse_selector(selector_str)?;
373
374        let annotations: Vec<&Annotation> = match file {
375            Some(f) => self.get_file_annotations(f),
376            None => match scope {
377                Some(s) if !s.is_empty() => self.get_scope_annotations(&Scope::from(s)),
378                _ => self.get_all_annotations(),
379            },
380        };
381
382        let flat = flatten_annotations(&annotations);
383        let matchable_refs: Vec<&dyn Matchable> =
384            flat.iter().map(|n| n as &dyn Matchable).collect();
385        let parent_indices: Vec<Option<usize>> = flat.iter().map(|n| n.parent_idx).collect();
386        let matched_indices =
387            matcher::filter_by_selector_indexed(&matchable_refs, &parent_indices, &selector);
388
389        let results: Vec<Annotation> = matched_indices
390            .into_iter()
391            .map(|idx| flat[idx].ann.clone())
392            .collect();
393
394        let results = match opts {
395            Some(o) => crate::query_options::apply_to_annotations(results, o),
396            None => results,
397        };
398
399        Ok(results)
400    }
401
402    /// Refresh all sidecar files whose rel_source starts with `scope`.
403    /// Also discovers newly added sidecar files via the locator.
404    /// Returns parse errors encountered during discovery.
405    /// If scope is empty, refreshes all files.
406    #[cfg(feature = "fs")]
407    pub fn refresh_scope(&mut self, scope: &Scope) -> Vec<String> {
408        let mut errors = Vec::new();
409
410        // Refresh existing entries
411        let keys: Vec<RelativePath> = self
412            .index
413            .keys()
414            .filter(|k| scope.is_empty() || k.starts_with(&**scope))
415            .cloned()
416            .collect();
417        for key in keys {
418            self.refresh_if_stale(&key);
419        }
420
421        // Discover new sidecar files not yet in the index
422        let pairs = self.locator.discover(&self.project_root);
423        for (sidecar_path, rel_source) in pairs {
424            if !(scope.is_empty() || rel_source.starts_with(&**scope)) {
425                continue;
426            }
427            if self.index.contains_key(&*rel_source) {
428                continue;
429            }
430            // New file — load it
431            if let (Ok(meta), Ok(raw)) = (
432                std::fs::metadata(&sidecar_path),
433                std::fs::read_to_string(&sidecar_path),
434            ) {
435                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
436                match parse::parse_sidecar(&raw, &rel_source) {
437                    Ok(annotations) => {
438                        self.index.insert(
439                            rel_source,
440                            CachedFileEntry {
441                                annotations,
442                                mtime,
443                                sidecar_path,
444                            },
445                        );
446                    }
447                    Err(e) => errors.push(e),
448                }
449            }
450        }
451        errors
452    }
453
454    /// Get all annotations grouped by file within a scope prefix.
455    pub fn annotations_in_scope(&self, scope: &Scope) -> Vec<(&RelativePath, Vec<&Annotation>)> {
456        self.index
457            .iter()
458            .filter(|(rel, _)| scope.is_empty() || rel.starts_with(&**scope))
459            .map(|(rel, entry)| (rel, entry.annotations.iter().collect()))
460            .collect()
461    }
462
463    /// Number of loaded annotation files.
464    pub fn file_count(&self) -> usize {
465        self.index.len()
466    }
467
468    /// All source file paths that have annotations.
469    pub fn annotated_files(&self) -> Vec<RelativePath> {
470        self.index.keys().cloned().collect()
471    }
472
473    /// Load annotations from an XML string for a given relative source path.
474    /// Unlike `load_file`, this does not touch the filesystem.
475    /// Returns `Err` if the XML is malformed.
476    pub fn load_xml(&mut self, rel_source: &RelativePath, xml: &str) -> Result<(), AqlError> {
477        let annotations = parse::parse_sidecar(xml, rel_source).map_err(AqlError::from)?;
478        self.index.insert(
479            rel_source.clone(),
480            CachedFileEntry {
481                annotations,
482                mtime: SystemTime::UNIX_EPOCH,
483                sidecar_path: PathBuf::new(),
484            },
485        );
486        Ok(())
487    }
488
489    /// Load annotations from extractor output, merging with existing sidecar data.
490    ///
491    /// For bindings that already exist in sidecar files, sidecar attributes take
492    /// precedence — extractor attrs are only used as defaults.
493    pub fn load_extractor_output(&mut self, annotations: Vec<Annotation>) {
494        for ann in annotations {
495            let rel = ann.file.clone();
496            let entry = self.index.entry(rel).or_insert_with(|| CachedFileEntry {
497                annotations: Vec::new(),
498                mtime: SystemTime::UNIX_EPOCH,
499                sidecar_path: PathBuf::new(),
500            });
501
502            // Check if a sidecar annotation already covers this binding+tag
503            let existing = entry.annotations.iter_mut().find(|a| {
504                !a.binding.is_empty()
505                    && !ann.binding.is_empty()
506                    && a.binding == ann.binding
507                    && a.tag == ann.tag
508            });
509
510            match existing {
511                Some(existing_ann) => {
512                    // Sidecar overrides extractor: only fill in attrs the sidecar lacks
513                    for (key, value) in &ann.attrs {
514                        existing_ann
515                            .attrs
516                            .entry(key.clone())
517                            .or_insert(value.clone());
518                    }
519                }
520                None => {
521                    entry.annotations.push(ann);
522                }
523            }
524        }
525    }
526
527    /// Inject test data directly (for unit tests).
528    #[cfg(test)]
529    pub fn inject_test_data(&mut self, rel_source: &RelativePath, annotations: Vec<Annotation>) {
530        let sidecar_rel = self.locator.sidecar_for(rel_source);
531        let sidecar_path = self.project_root.join(AsRef::<Path>::as_ref(&sidecar_rel));
532
533        self.index.insert(
534            rel_source.clone(),
535            CachedFileEntry {
536                annotations,
537                mtime: SystemTime::now(),
538                sidecar_path,
539            },
540        );
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Matchable wrapper for annotations (zero-copy: borrows from &Annotation)
546// ---------------------------------------------------------------------------
547
548struct AnnotationNode<'a> {
549    ann: &'a Annotation,
550    parent_idx: Option<usize>,
551}
552
553impl Matchable for AnnotationNode<'_> {
554    fn tag(&self) -> &TagName {
555        &self.ann.tag
556    }
557    fn attrs(&self) -> &FxHashMap<AttrName, JsonValue> {
558        &self.ann.attrs
559    }
560    fn parent(&self) -> Option<&dyn Matchable> {
561        None
562    }
563}
564
565fn flatten_annotations<'a>(annotations: &[&'a Annotation]) -> Vec<AnnotationNode<'a>> {
566    let mut result = Vec::new();
567
568    fn walk<'a>(
569        ann: &'a Annotation,
570        parent_idx: Option<usize>,
571        result: &mut Vec<AnnotationNode<'a>>,
572    ) {
573        let idx = result.len();
574        result.push(AnnotationNode { ann, parent_idx });
575        for child in &ann.children {
576            walk(child, Some(idx), result);
577        }
578    }
579
580    for ann in annotations {
581        walk(ann, None, &mut result);
582    }
583    result
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn loads_xml_from_string() {
592        // Arrange
593        let mut store = AnnotationStore::new(Path::new("/project"));
594        let xml = r#"<controller method="POST" bind="handle_create" />"#;
595
596        // Act
597        store
598            .load_xml(&RelativePath::from("src/api.ts"), xml)
599            .unwrap();
600        let results = store.select("controller", None, None, None).unwrap();
601
602        // Assert
603        assert_eq!(store.file_count(), 1, "should have one loaded file");
604        assert_eq!(results.len(), 1, "should find one controller annotation");
605        assert_eq!(results[0].tag, "controller", "tag should be controller");
606        assert_eq!(
607            results[0].file, "src/api.ts",
608            "file should match rel_source"
609        );
610        assert_eq!(
611            results[0].binding, "handle_create",
612            "binding should come from bind attr"
613        );
614    }
615
616    #[test]
617    fn selects_annotations() {
618        // Arrange
619        let mut store = AnnotationStore::new(Path::new("/project"));
620        store.inject_test_data(
621            &RelativePath::from("src/api.ts"),
622            vec![
623                Annotation {
624                    tag: TagName::from("controller"),
625                    attrs: {
626                        let mut m = FxHashMap::default();
627                        m.insert(
628                            AttrName::from("method"),
629                            JsonValue::String("POST".to_string()),
630                        );
631                        m
632                    },
633                    binding: Binding::from(""),
634                    file: RelativePath::from("src/api.ts"),
635                    children: vec![],
636                },
637                Annotation {
638                    tag: TagName::from("react-hook"),
639                    attrs: FxHashMap::default(),
640                    binding: Binding::from(""),
641                    file: RelativePath::from("src/api.ts"),
642                    children: vec![],
643                },
644            ],
645        );
646        store.inject_test_data(
647            &RelativePath::from("src/b.ts"),
648            vec![Annotation {
649                tag: TagName::from("controller"),
650                attrs: FxHashMap::default(),
651                binding: Binding::from(""),
652                file: RelativePath::from("src/b.ts"),
653                children: vec![],
654            }],
655        );
656
657        // Act
658        let by_tag = store.select("controller", None, None, None).unwrap();
659        let by_attr_match = store
660            .select(r#"controller[method="POST"]"#, None, None, None)
661            .unwrap();
662        let by_attr_miss = store
663            .select(r#"controller[method="GET"]"#, None, None, None)
664            .unwrap();
665        let scoped = store
666            .select("controller", Some("src/api.ts"), None, None)
667            .unwrap();
668
669        // Assert
670        assert_eq!(by_tag.len(), 2);
671        assert!(by_tag.iter().all(|r| r.tag == "controller"));
672
673        assert_eq!(by_attr_match.len(), 1);
674        assert_eq!(by_attr_miss.len(), 0);
675
676        assert_eq!(scoped.len(), 1);
677        assert_eq!(scoped[0].file, "src/api.ts");
678    }
679}