Skip to main content

docx_core/control/
data.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2
3use docx_store::models::{DocBlock, DocSource, RelationRecord, Symbol};
4use docx_store::schema::{
5    REL_CONTAINS, REL_INHERITS, REL_MEMBER_OF, REL_OBSERVED_IN, REL_PARAM_TYPE, REL_REFERENCES,
6    REL_RETURNS, REL_SEE_ALSO, TABLE_DOC_BLOCK, TABLE_DOC_SOURCE, TABLE_SYMBOL,
7};
8use surrealdb::Connection;
9
10use crate::store::StoreError;
11
12use super::{ControlError, DocxControlPlane};
13
14const ADVANCED_SEARCH_MIN_FILTERS: usize = 1;
15
16impl<C: Connection> DocxControlPlane<C> {
17    /// Fetches a symbol by project and key.
18    ///
19    /// # Errors
20    /// Returns `ControlError` if the store query fails.
21    pub async fn get_symbol(
22        &self,
23        project_id: &str,
24        symbol_key: &str,
25    ) -> Result<Option<Symbol>, ControlError> {
26        Ok(self
27            .store
28            .get_symbol_by_project(project_id, symbol_key)
29            .await?)
30    }
31
32    /// Lists document blocks for a symbol, optionally scoping by ingest id.
33    ///
34    /// # Errors
35    /// Returns `ControlError` if the store query fails.
36    pub async fn list_doc_blocks(
37        &self,
38        project_id: &str,
39        symbol_key: &str,
40        ingest_id: Option<&str>,
41    ) -> Result<Vec<DocBlock>, ControlError> {
42        Ok(self
43            .store
44            .list_doc_blocks(project_id, symbol_key, ingest_id)
45            .await?)
46    }
47
48    /// Searches symbols by name.
49    ///
50    /// # Errors
51    /// Returns `ControlError` if the store query fails.
52    pub async fn search_symbols(
53        &self,
54        project_id: &str,
55        name: &str,
56        limit: usize,
57    ) -> Result<Vec<Symbol>, ControlError> {
58        Ok(self
59            .store
60            .list_symbols_by_name(project_id, name, limit)
61            .await?)
62    }
63
64    /// Searches symbols with optional exact/fuzzy filters.
65    ///
66    /// # Errors
67    /// Returns `ControlError` if no filters are provided or the store query fails.
68    pub async fn search_symbols_advanced(
69        &self,
70        project_id: &str,
71        request: SearchSymbolsAdvancedRequest,
72        limit: usize,
73    ) -> Result<SearchSymbolsAdvancedResult, ControlError> {
74        let normalized = request.normalized();
75        if normalized.active_filter_count() < ADVANCED_SEARCH_MIN_FILTERS {
76            return Err(ControlError::Store(StoreError::InvalidInput(
77                "at least one search filter is required".to_string(),
78            )));
79        }
80
81        let symbols = self
82            .store
83            .search_symbols_advanced(
84                project_id,
85                normalized.name.as_deref(),
86                normalized.qualified_name.as_deref(),
87                normalized.symbol_key.as_deref(),
88                normalized.signature.as_deref(),
89                limit,
90            )
91            .await?;
92        let total_returned = symbols.len();
93
94        Ok(SearchSymbolsAdvancedResult {
95            symbols,
96            total_returned,
97            applied_filters: normalized,
98        })
99    }
100
101    /// Searches document blocks by text.
102    ///
103    /// # Errors
104    /// Returns `ControlError` if the store query fails.
105    pub async fn search_doc_blocks(
106        &self,
107        project_id: &str,
108        text: &str,
109        limit: usize,
110    ) -> Result<Vec<DocBlock>, ControlError> {
111        Ok(self
112            .store
113            .search_doc_blocks(project_id, text, limit)
114            .await?)
115    }
116
117    /// Lists distinct symbol kinds for a project.
118    ///
119    /// # Errors
120    /// Returns `ControlError` if the store query fails.
121    pub async fn list_symbol_kinds(&self, project_id: &str) -> Result<Vec<String>, ControlError> {
122        Ok(self.store.list_symbol_kinds(project_id).await?)
123    }
124
125    /// Audits high-level documentation graph completeness for a project.
126    ///
127    /// # Errors
128    /// Returns `ControlError` if the store query fails.
129    pub async fn audit_project_completeness(
130        &self,
131        project_id: &str,
132    ) -> Result<ProjectCompletenessAudit, ControlError> {
133        let symbol_count = self
134            .store
135            .count_rows_for_project(TABLE_SYMBOL, project_id)
136            .await?;
137        let doc_block_count = self
138            .store
139            .count_rows_for_project(TABLE_DOC_BLOCK, project_id)
140            .await?;
141        let doc_source_count = self
142            .store
143            .count_rows_for_project(TABLE_DOC_SOURCE, project_id)
144            .await?;
145
146        let symbols_missing_source_path_count = self
147            .store
148            .count_symbols_missing_field(project_id, "source_path")
149            .await?;
150        let symbols_missing_line_count = self
151            .store
152            .count_symbols_missing_field(project_id, "line")
153            .await?;
154        let symbols_missing_col_count = self
155            .store
156            .count_symbols_missing_field(project_id, "col")
157            .await?;
158
159        let doc_block_symbol_keys = self.store.list_doc_block_symbol_keys(project_id).await?;
160        let symbols_with_doc_blocks_count = doc_block_symbol_keys
161            .into_iter()
162            .collect::<HashSet<_>>()
163            .len();
164
165        let observed_in_symbols = self.store.list_observed_in_symbol_refs(project_id).await?;
166        let symbols_with_observed_in_count = observed_in_symbols
167            .into_iter()
168            .collect::<HashSet<_>>()
169            .len();
170
171        let relation_edge_counts = relation_names()
172            .into_iter()
173            .map(|relation| async move {
174                let count = self
175                    .store
176                    .count_rows_for_project(relation, project_id)
177                    .await?;
178                Ok::<RelationEdgeCount, ControlError>(RelationEdgeCount {
179                    relation: relation.to_string(),
180                    count,
181                })
182            })
183            .collect::<Vec<_>>();
184
185        let mut relation_edge_counts = futures::future::try_join_all(relation_edge_counts).await?;
186        relation_edge_counts.sort_by(|left, right| left.relation.cmp(&right.relation));
187
188        let relation_counts = relation_edge_counts
189            .iter()
190            .map(|entry| (entry.relation.clone(), entry.count))
191            .collect::<BTreeMap<_, _>>();
192
193        Ok(ProjectCompletenessAudit {
194            project_id: project_id.to_string(),
195            symbol_count,
196            doc_block_count,
197            doc_source_count,
198            symbols_missing_source_path_count,
199            symbols_missing_line_count,
200            symbols_missing_col_count,
201            symbols_with_doc_blocks_count,
202            symbols_with_observed_in_count,
203            relation_counts,
204            relation_edge_counts,
205        })
206    }
207
208    /// Lists members by scope prefix or glob pattern.
209    ///
210    /// # Errors
211    /// Returns `ControlError` if the store query fails.
212    pub async fn list_members_by_scope(
213        &self,
214        project_id: &str,
215        scope: &str,
216        limit: usize,
217    ) -> Result<Vec<Symbol>, ControlError> {
218        Ok(self
219            .store
220            .list_members_by_scope(project_id, scope, limit)
221            .await?)
222    }
223
224    /// Fetches adjacency information for a symbol, including relations and related symbols.
225    ///
226    /// Uses a single multi-statement query for all relation types to minimize DB round trips.
227    ///
228    /// # Errors
229    /// Returns `ControlError` if the store query fails.
230    pub async fn get_symbol_adjacency(
231        &self,
232        project_id: &str,
233        symbol_key: &str,
234        limit: usize,
235    ) -> Result<SymbolAdjacency, ControlError> {
236        let limit = limit.max(1);
237        let symbol = self.get_symbol(project_id, symbol_key).await?;
238        let Some(symbol) = symbol else {
239            return Ok(SymbolAdjacency::default());
240        };
241        let doc_blocks = self.list_doc_blocks(project_id, symbol_key, None).await?;
242        let mut ingest_ids = doc_blocks
243            .iter()
244            .filter_map(|block| block.ingest_id.clone())
245            .collect::<Vec<_>>();
246        ingest_ids.sort();
247        ingest_ids.dedup();
248        let symbol_id = symbol
249            .id
250            .clone()
251            .unwrap_or_else(|| symbol.symbol_key.clone());
252
253        let adj = self
254            .store
255            .fetch_symbol_adjacency(&symbol_id, project_id, limit)
256            .await?;
257
258        let doc_sources_from_doc_blocks =
259            self.store.list_doc_sources(project_id, &ingest_ids).await?;
260        let observed_doc_source_ids = adj
261            .observed_in
262            .iter()
263            .filter_map(|edge| record_id_to_doc_source_id(&edge.out_id))
264            .map(str::to_string)
265            .collect::<BTreeSet<_>>()
266            .into_iter()
267            .collect::<Vec<_>>();
268        let doc_sources_from_observed_in = self
269            .store
270            .list_doc_sources_by_ids(project_id, &observed_doc_source_ids)
271            .await?;
272        let hydration_summary = DocSourceHydrationSummary {
273            from_doc_blocks: doc_sources_from_doc_blocks.len(),
274            from_observed_in: doc_sources_from_observed_in.len(),
275            deduped_total: 0,
276        };
277        let (doc_sources, hydration_summary) = merge_doc_sources(
278            doc_sources_from_doc_blocks,
279            doc_sources_from_observed_in,
280            hydration_summary,
281        );
282
283        let mut related_keys = std::collections::HashSet::new();
284        for relation in adj
285            .member_of
286            .iter()
287            .chain(adj.contains.iter())
288            .chain(adj.returns.iter())
289            .chain(adj.param_types.iter())
290            .chain(adj.see_also.iter())
291            .chain(adj.inherits.iter())
292            .chain(adj.references.iter())
293            .chain(adj.observed_in.iter())
294        {
295            if let Some(key) = record_id_to_symbol_key(&relation.in_id) {
296                related_keys.insert(key.to_string());
297            }
298            if let Some(key) = record_id_to_symbol_key(&relation.out_id) {
299                related_keys.insert(key.to_string());
300            }
301        }
302
303        let related_keys: Vec<String> = related_keys.into_iter().collect();
304        let related_futs: Vec<_> = related_keys
305            .iter()
306            .map(|key| self.get_symbol(project_id, key))
307            .collect();
308        let related_results = futures::future::join_all(related_futs).await;
309        let mut related_symbols: Vec<Symbol> = related_results
310            .into_iter()
311            .filter_map(|r| r.ok().flatten())
312            .collect();
313        related_symbols.sort_by(|left, right| left.symbol_key.cmp(&right.symbol_key));
314        related_symbols.dedup_by(|left, right| left.symbol_key == right.symbol_key);
315
316        Ok(SymbolAdjacency {
317            symbol: Some(symbol),
318            doc_blocks,
319            doc_sources,
320            hydration_summary,
321            member_of: adj.member_of,
322            contains: adj.contains,
323            returns: adj.returns,
324            param_types: adj.param_types,
325            see_also: adj.see_also,
326            inherits: adj.inherits,
327            references: adj.references,
328            observed_in: adj.observed_in,
329            related_symbols,
330        })
331    }
332}
333
334/// Relation graph data for a symbol.
335#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
336pub struct SymbolAdjacency {
337    pub symbol: Option<Symbol>,
338    pub doc_blocks: Vec<DocBlock>,
339    pub doc_sources: Vec<DocSource>,
340    pub hydration_summary: DocSourceHydrationSummary,
341    pub member_of: Vec<RelationRecord>,
342    pub contains: Vec<RelationRecord>,
343    pub returns: Vec<RelationRecord>,
344    pub param_types: Vec<RelationRecord>,
345    pub see_also: Vec<RelationRecord>,
346    pub inherits: Vec<RelationRecord>,
347    pub references: Vec<RelationRecord>,
348    pub observed_in: Vec<RelationRecord>,
349    pub related_symbols: Vec<Symbol>,
350}
351
352/// Summary of where adjacency `doc_sources` were hydrated from.
353#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
354pub struct DocSourceHydrationSummary {
355    pub from_doc_blocks: usize,
356    pub from_observed_in: usize,
357    pub deduped_total: usize,
358}
359
360/// Input filters for advanced symbol search.
361#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
362pub struct SearchSymbolsAdvancedRequest {
363    pub name: Option<String>,
364    pub qualified_name: Option<String>,
365    pub symbol_key: Option<String>,
366    pub signature: Option<String>,
367}
368
369impl SearchSymbolsAdvancedRequest {
370    #[must_use]
371    pub fn normalized(self) -> Self {
372        Self {
373            name: normalize_optional(self.name),
374            qualified_name: normalize_optional(self.qualified_name),
375            symbol_key: normalize_optional(self.symbol_key),
376            signature: normalize_optional(self.signature),
377        }
378    }
379
380    #[must_use]
381    pub fn active_filter_count(&self) -> usize {
382        [
383            self.name.as_ref(),
384            self.qualified_name.as_ref(),
385            self.symbol_key.as_ref(),
386            self.signature.as_ref(),
387        ]
388        .iter()
389        .filter(|value| value.is_some())
390        .count()
391    }
392}
393
394/// Output payload for advanced symbol search.
395#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
396pub struct SearchSymbolsAdvancedResult {
397    pub symbols: Vec<Symbol>,
398    pub total_returned: usize,
399    pub applied_filters: SearchSymbolsAdvancedRequest,
400}
401
402/// Relation edge counts used in project completeness audits.
403#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
404pub struct RelationEdgeCount {
405    pub relation: String,
406    pub count: usize,
407}
408
409/// Project-level completeness audit report.
410#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
411pub struct ProjectCompletenessAudit {
412    pub project_id: String,
413    pub symbol_count: usize,
414    pub doc_block_count: usize,
415    pub doc_source_count: usize,
416    pub symbols_missing_source_path_count: usize,
417    pub symbols_missing_line_count: usize,
418    pub symbols_missing_col_count: usize,
419    pub symbols_with_doc_blocks_count: usize,
420    pub symbols_with_observed_in_count: usize,
421    pub relation_counts: BTreeMap<String, usize>,
422    pub relation_edge_counts: Vec<RelationEdgeCount>,
423}
424
425/// Extracts the symbol key from a table-qualified record id.
426fn record_id_to_symbol_key(record_id: &str) -> Option<&str> {
427    record_id.strip_prefix("symbol:")
428}
429
430/// Extracts a doc-source id from a table-qualified record id.
431fn record_id_to_doc_source_id(record_id: &str) -> Option<&str> {
432    record_id.strip_prefix("doc_source:")
433}
434
435fn merge_doc_sources(
436    from_doc_blocks: Vec<DocSource>,
437    from_observed_in: Vec<DocSource>,
438    mut summary: DocSourceHydrationSummary,
439) -> (Vec<DocSource>, DocSourceHydrationSummary) {
440    let mut all = from_doc_blocks;
441    all.extend(from_observed_in);
442
443    let mut seen = HashSet::new();
444    all.retain(|source| {
445        let key = source
446            .id
447            .clone()
448            .unwrap_or_else(|| format!("missing:{}", source.project_id));
449        seen.insert(key)
450    });
451    all.sort_by(|left, right| {
452        let left_key = left.id.as_deref().unwrap_or_default();
453        let right_key = right.id.as_deref().unwrap_or_default();
454        left_key.cmp(right_key)
455    });
456    summary.deduped_total = all.len();
457    (all, summary)
458}
459
460fn normalize_optional(value: Option<String>) -> Option<String> {
461    value.and_then(|inner| {
462        let trimmed = inner.trim();
463        if trimmed.is_empty() {
464            None
465        } else {
466            Some(trimmed.to_string())
467        }
468    })
469}
470
471fn relation_names() -> Vec<&'static str> {
472    vec![
473        REL_MEMBER_OF,
474        REL_CONTAINS,
475        REL_RETURNS,
476        REL_PARAM_TYPE,
477        REL_SEE_ALSO,
478        REL_INHERITS,
479        REL_REFERENCES,
480        REL_OBSERVED_IN,
481    ]
482}