Skip to main content

sqry_core/
json_response.rs

1//! Structured JSON response types for programmatic tool integration
2//!
3//! This module provides standardized response wrappers that include query metadata,
4//! statistics, and results in a consistent format across all sqry commands.
5
6use serde::Serialize;
7use std::collections::HashMap;
8use std::time::Duration;
9
10use crate::confidence::ConfidenceMetadata;
11
12/// Query metadata included in all responses
13#[derive(Debug, Clone, Serialize)]
14pub struct QueryMeta {
15    /// The search pattern or query expression
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub pattern: Option<String>,
18
19    /// Active filters applied to the query
20    #[serde(skip_serializing_if = "Filters::is_empty")]
21    pub filters: Filters,
22
23    /// Query execution time in milliseconds
24    pub execution_time_ms: f64,
25
26    /// Optional query execution plan (when explain=true)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub plan: Option<String>,
29
30    /// Confidence metadata for the analysis (Rust-specific)
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub confidence: Option<ConfidenceMetadata>,
33}
34
35impl QueryMeta {
36    /// Create new query metadata
37    #[must_use]
38    pub fn new(pattern: Option<String>, execution_time: Duration) -> Self {
39        Self {
40            pattern,
41            filters: Filters::default(),
42            execution_time_ms: execution_time.as_secs_f64() * 1000.0,
43            plan: None,
44            confidence: None,
45        }
46    }
47
48    /// Set filters
49    #[must_use]
50    pub fn with_filters(mut self, filters: Filters) -> Self {
51        self.filters = filters;
52        self
53    }
54
55    /// Set execution plan
56    #[must_use]
57    pub fn with_plan(mut self, plan: String) -> Self {
58        self.plan = Some(plan);
59        self
60    }
61
62    /// Set confidence metadata
63    #[must_use]
64    pub fn with_confidence(mut self, confidence: ConfidenceMetadata) -> Self {
65        self.confidence = Some(confidence);
66        self
67    }
68}
69
70/// Filters applied to a query
71#[derive(Debug, Clone, Default, Serialize)]
72pub struct Filters {
73    /// Node kind filter (function, class, etc.)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub kind: Option<String>,
76
77    /// Language filter
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub lang: Option<String>,
80
81    /// Case-insensitive flag
82    #[serde(skip_serializing_if = "std::ops::Not::not")]
83    pub ignore_case: bool,
84
85    /// Exact match flag
86    #[serde(skip_serializing_if = "std::ops::Not::not")]
87    pub exact: bool,
88
89    /// Fuzzy search settings
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub fuzzy: Option<FuzzyFilters>,
92}
93
94impl Filters {
95    /// Check if filters are empty
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.kind.is_none()
99            && self.lang.is_none()
100            && !self.ignore_case
101            && !self.exact
102            && self.fuzzy.is_none()
103    }
104}
105
106/// Fuzzy search specific filters
107#[derive(Debug, Clone, Serialize)]
108pub struct FuzzyFilters {
109    /// Fuzzy matching algorithm
110    pub algorithm: String,
111
112    /// Minimum similarity threshold (0.0-1.0)
113    pub threshold: f64,
114
115    /// Maximum candidates to consider
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub max_candidates: Option<usize>,
118}
119
120/// Statistics about the query results
121#[derive(Debug, Clone, Serialize)]
122pub struct Stats {
123    /// Total number of matches found
124    pub total_matches: usize,
125
126    /// Number of results returned (after limiting)
127    pub returned: usize,
128
129    /// Whether results were truncated due to limit
130    #[serde(rename = "truncated")]
131    pub is_truncated: bool,
132
133    /// Age of the index in seconds (if applicable)
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub index_age_seconds: Option<u64>,
136
137    /// Number of candidates generated (for fuzzy search)
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub candidate_count: Option<usize>,
140
141    /// Number of candidates after filtering (for fuzzy search)
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub filtered_count: Option<usize>,
144
145    /// True if the index was found in an ancestor directory
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub used_ancestor_index: Option<bool>,
148
149    /// Scope filter applied (relative path like "src/**" or "main.rs")
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub filtered_to: Option<String>,
152}
153
154impl Stats {
155    /// Create new stats with basic counts
156    #[must_use]
157    pub fn new(total: usize, returned: usize) -> Self {
158        Self {
159            total_matches: total,
160            returned,
161            is_truncated: returned < total,
162            index_age_seconds: None,
163            candidate_count: None,
164            filtered_count: None,
165            used_ancestor_index: None,
166            filtered_to: None,
167        }
168    }
169
170    /// Set index age
171    #[must_use]
172    pub fn with_index_age(mut self, age_seconds: u64) -> Self {
173        self.index_age_seconds = Some(age_seconds);
174        self
175    }
176
177    /// Set candidate counts (for fuzzy search)
178    #[must_use]
179    pub fn with_candidates(mut self, total: usize, filtered: usize) -> Self {
180        self.candidate_count = Some(total);
181        self.filtered_count = Some(filtered);
182        self
183    }
184
185    /// Set scope info when using ancestor index discovery
186    #[must_use]
187    pub fn with_scope_info(mut self, is_ancestor: bool, filtered_to: Option<String>) -> Self {
188        self.used_ancestor_index = Some(is_ancestor);
189        self.filtered_to = filtered_to;
190        self
191    }
192}
193
194/// Structured JSON response wrapper
195#[derive(Debug, Clone, Serialize)]
196pub struct JsonResponse<T> {
197    /// Query metadata
198    pub query: QueryMeta,
199
200    /// Result statistics
201    pub stats: Stats,
202
203    /// The actual results
204    pub results: Vec<T>,
205}
206
207impl<T> JsonResponse<T> {
208    /// Create a new JSON response
209    #[must_use]
210    pub fn new(query: QueryMeta, stats: Stats, results: Vec<T>) -> Self {
211        Self {
212            query,
213            stats,
214            results,
215        }
216    }
217}
218
219/// Streaming event for incremental results
220#[derive(Debug, Clone, Serialize)]
221#[serde(tag = "event", rename_all = "snake_case")]
222pub enum StreamEvent<T> {
223    /// Partial result with score
224    PartialResult {
225        /// The result item
226        result: T,
227        /// Match score (0.0-1.0)
228        score: f64,
229    },
230
231    /// Final summary with complete statistics
232    FinalSummary {
233        /// Final statistics
234        stats: Stats,
235    },
236}
237
238/// Index status information for programmatic consumers
239#[derive(Debug, Clone, Serialize)]
240pub struct IndexStatus {
241    /// Whether an index exists
242    pub exists: bool,
243
244    /// Path to the index directory
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub path: Option<String>,
247
248    /// When the index was created (ISO 8601 format)
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub created_at: Option<String>,
251
252    /// Age of the index in seconds
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub age_seconds: Option<u64>,
255
256    /// Total number of symbols indexed
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub symbol_count: Option<usize>,
259
260    /// Number of files indexed
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub file_count: Option<usize>,
263
264    /// Languages found in the index
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub languages: Option<Vec<String>>,
267
268    /// Whether fuzzy search is supported
269    pub supports_fuzzy: bool,
270
271    /// Whether relation queries are supported
272    pub supports_relations: bool,
273
274    /// Total count of cross-language relations
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub cross_language_relation_count: Option<usize>,
277
278    /// Node counts grouped by kind (e.g., {"function": 25669, "class": 332})
279    /// Used for tree view grouping in `VSCode` extension
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub symbol_counts_by_kind: Option<HashMap<String, usize>>,
282
283    /// File counts grouped by language (e.g., {"rust": 1245, "javascript": 523})
284    /// Used for tree view grouping in `VSCode` extension
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub file_counts_by_language: Option<HashMap<String, usize>>,
287
288    /// Relation counts grouped by language pair (e.g., {"go→javascript": 45})
289    /// Used for tree view grouping in `VSCode` extension
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub relation_counts_by_pair: Option<HashMap<String, usize>>,
292
293    /// Whether index is considered stale (>24 hours old)
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub stale: Option<bool>,
296
297    /// Whether an index build is currently in progress (lock file exists)
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub building: Option<bool>,
300
301    /// Age of the lock file in seconds (if build is in progress)
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub build_age_seconds: Option<u64>,
304}
305
306impl IndexStatus {
307    /// Create a new index status for non-existent index
308    #[must_use]
309    pub fn not_found() -> Self {
310        Self {
311            exists: false,
312            path: None,
313            created_at: None,
314            age_seconds: None,
315            symbol_count: None,
316            file_count: None,
317            languages: None,
318            supports_fuzzy: false,
319            supports_relations: false,
320            cross_language_relation_count: None,
321            symbol_counts_by_kind: None,
322            file_counts_by_language: None,
323            relation_counts_by_pair: None,
324            stale: None,
325            building: None,
326            build_age_seconds: None,
327        }
328    }
329
330    /// Create index status from metadata (builder pattern to avoid too many args)
331    #[must_use]
332    pub fn from_index(path: String, created_at: String, age_seconds: u64) -> IndexStatusBuilder {
333        IndexStatusBuilder {
334            path,
335            created_at,
336            age_seconds,
337            symbol_count: 0,
338            file_count: None,
339            languages: Vec::new(),
340            has_relations: false,
341            has_trigram: false,
342            cross_language_relation_count: 0,
343            symbol_counts_by_kind: None,
344            file_counts_by_language: None,
345            relation_counts_by_pair: None,
346            building: None,
347            build_age_seconds: None,
348        }
349    }
350}
351
352/// Builder for `IndexStatus` to avoid too many arguments
353pub struct IndexStatusBuilder {
354    path: String,
355    created_at: String,
356    age_seconds: u64,
357    symbol_count: usize,
358    file_count: Option<usize>,
359    languages: Vec<String>,
360    has_relations: bool,
361    has_trigram: bool,
362    cross_language_relation_count: usize,
363    symbol_counts_by_kind: Option<HashMap<String, usize>>,
364    file_counts_by_language: Option<HashMap<String, usize>>,
365    relation_counts_by_pair: Option<HashMap<String, usize>>,
366    building: Option<bool>,
367    build_age_seconds: Option<u64>,
368}
369
370impl IndexStatusBuilder {
371    /// Set the symbol count
372    #[must_use]
373    pub fn symbol_count(mut self, count: usize) -> Self {
374        self.symbol_count = count;
375        self
376    }
377
378    /// Set the file count
379    #[must_use]
380    pub fn file_count(mut self, count: usize) -> Self {
381        self.file_count = Some(count);
382        self
383    }
384
385    /// Set the file count as optional (None when unknown)
386    #[must_use]
387    pub fn file_count_opt(mut self, count: Option<usize>) -> Self {
388        self.file_count = count;
389        self
390    }
391
392    /// Set the languages list
393    #[must_use]
394    pub fn languages(mut self, langs: Vec<String>) -> Self {
395        self.languages = langs;
396        self
397    }
398
399    /// Set whether relations are present
400    #[must_use]
401    pub fn has_relations(mut self, value: bool) -> Self {
402        self.has_relations = value;
403        self
404    }
405
406    /// Set whether trigram index exists (for fuzzy search support)
407    #[must_use]
408    pub fn has_trigram(mut self, value: bool) -> Self {
409        self.has_trigram = value;
410        self
411    }
412
413    /// Set the cross-language relation count
414    #[must_use]
415    pub fn cross_language_relation_count(mut self, count: usize) -> Self {
416        self.cross_language_relation_count = count;
417        self
418    }
419
420    /// Set symbol counts grouped by kind (for tree view grouping)
421    #[must_use]
422    pub fn symbol_counts_by_kind(mut self, counts: HashMap<String, usize>) -> Self {
423        self.symbol_counts_by_kind = Some(counts);
424        self
425    }
426
427    /// Set file counts grouped by language (for tree view grouping)
428    #[must_use]
429    pub fn file_counts_by_language(mut self, counts: HashMap<String, usize>) -> Self {
430        self.file_counts_by_language = Some(counts);
431        self
432    }
433
434    /// Set relation counts grouped by language pair (for tree view grouping)
435    #[must_use]
436    pub fn relation_counts_by_pair(mut self, counts: HashMap<String, usize>) -> Self {
437        self.relation_counts_by_pair = Some(counts);
438        self
439    }
440
441    /// Set whether a build is currently in progress
442    #[must_use]
443    pub fn building(mut self, value: bool) -> Self {
444        self.building = Some(value);
445        self
446    }
447
448    /// Set the age of the build lock in seconds
449    #[must_use]
450    pub fn build_age_seconds(mut self, value: u64) -> Self {
451        self.build_age_seconds = Some(value);
452        self
453    }
454
455    /// Build the `IndexStatus`
456    #[must_use]
457    pub fn build(self) -> IndexStatus {
458        let stale = self.age_seconds > 86400; // >24 hours
459        IndexStatus {
460            exists: true,
461            path: Some(self.path),
462            created_at: Some(self.created_at),
463            age_seconds: Some(self.age_seconds),
464            symbol_count: Some(self.symbol_count),
465            file_count: self.file_count,
466            languages: Some(self.languages),
467            supports_fuzzy: self.has_trigram,
468            supports_relations: self.has_relations,
469            cross_language_relation_count: if self.cross_language_relation_count > 0 {
470                Some(self.cross_language_relation_count)
471            } else {
472                None
473            },
474            symbol_counts_by_kind: self.symbol_counts_by_kind,
475            file_counts_by_language: self.file_counts_by_language,
476            relation_counts_by_pair: self.relation_counts_by_pair,
477            stale: Some(stale),
478            building: self.building,
479            build_age_seconds: self.build_age_seconds,
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use serde_json;
488
489    #[test]
490    fn test_query_meta_serialization() {
491        let meta = QueryMeta::new(Some("handler".to_string()), Duration::from_millis(5));
492
493        let json = serde_json::to_string(&meta).unwrap();
494        assert!(json.contains("\"pattern\":\"handler\""));
495        assert!(json.contains("\"execution_time_ms\":5"));
496    }
497
498    #[test]
499    fn test_filters_empty() {
500        let filters = Filters::default();
501        assert!(filters.is_empty());
502
503        let json = serde_json::to_value(&filters).unwrap();
504        assert_eq!(json.as_object().unwrap().len(), 0);
505    }
506
507    #[test]
508    fn test_filters_with_values() {
509        let filters = Filters {
510            kind: Some("function".to_string()),
511            lang: Some("rust".to_string()),
512            ignore_case: true,
513            ..Default::default()
514        };
515
516        assert!(!filters.is_empty());
517        let json = serde_json::to_string(&filters).unwrap();
518        assert!(json.contains("\"kind\":\"function\""));
519        assert!(json.contains("\"lang\":\"rust\""));
520        assert!(json.contains("\"ignore_case\":true"));
521    }
522
523    #[test]
524    fn test_stats_truncation() {
525        let stats = Stats::new(100, 50);
526        assert_eq!(stats.total_matches, 100);
527        assert_eq!(stats.returned, 50);
528        assert!(stats.is_truncated);
529
530        let stats_not_truncated = Stats::new(50, 50);
531        assert!(!stats_not_truncated.is_truncated);
532    }
533
534    #[test]
535    fn test_json_response() {
536        let meta = QueryMeta::new(Some("test".to_string()), Duration::from_millis(10));
537        let stats = Stats::new(5, 5);
538        let results = vec!["result1", "result2"];
539
540        let response = JsonResponse::new(meta, stats, results);
541
542        let json = serde_json::to_string(&response).unwrap();
543        assert!(json.contains("\"query\""));
544        assert!(json.contains("\"stats\""));
545        assert!(json.contains("\"results\""));
546    }
547
548    #[test]
549    fn test_stream_event_partial() {
550        let event = StreamEvent::PartialResult {
551            result: "test_symbol",
552            score: 0.95,
553        };
554
555        let json = serde_json::to_string(&event).unwrap();
556        assert!(json.contains("\"event\":\"partial_result\""));
557        assert!(json.contains("\"score\":0.95"));
558    }
559
560    #[test]
561    fn test_stream_event_final() {
562        let stats = Stats::new(42, 20);
563        // Specify type parameter since FinalSummary carries no `T`
564        let event: StreamEvent<()> = StreamEvent::FinalSummary { stats };
565
566        let json = serde_json::to_string(&event).unwrap();
567        assert!(json.contains("\"event\":\"final_summary\""));
568        assert!(json.contains("\"total_matches\":42"));
569    }
570
571    #[test]
572    fn test_query_meta_with_confidence() {
573        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
574
575        let confidence = ConfidenceMetadata {
576            level: ConfidenceLevel::AstOnly,
577            limitations: vec!["Missing rust-analyzer".to_string()],
578            unavailable_features: vec!["Type inference".to_string()],
579        };
580
581        let meta = QueryMeta::new(Some("test_pattern".to_string()), Duration::from_millis(10))
582            .with_confidence(confidence.clone());
583
584        assert!(meta.confidence.is_some());
585        let meta_confidence = meta.confidence.unwrap();
586        assert_eq!(meta_confidence.level, ConfidenceLevel::AstOnly);
587        assert_eq!(meta_confidence.limitations.len(), 1);
588        assert_eq!(meta_confidence.unavailable_features.len(), 1);
589    }
590
591    #[test]
592    fn test_query_meta_confidence_serialization() {
593        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
594
595        let confidence = ConfidenceMetadata {
596            level: ConfidenceLevel::Partial,
597            limitations: vec!["Limited accuracy".to_string()],
598            unavailable_features: vec![],
599        };
600
601        let meta = QueryMeta::new(Some("search_pattern".to_string()), Duration::from_millis(5))
602            .with_confidence(confidence);
603
604        let json = serde_json::to_string(&meta).unwrap();
605        assert!(json.contains("\"confidence\""));
606        assert!(json.contains("\"partial\""));
607        assert!(json.contains("\"limitations\""));
608        assert!(json.contains("\"Limited accuracy\""));
609    }
610
611    #[test]
612    fn test_query_meta_without_confidence() {
613        let meta = QueryMeta::new(Some("test".to_string()), Duration::from_millis(5));
614
615        assert!(meta.confidence.is_none());
616
617        let json = serde_json::to_string(&meta).unwrap();
618        // None confidence should be omitted due to skip_serializing_if
619        assert!(!json.contains("\"confidence\""));
620    }
621
622    #[test]
623    fn test_json_response_with_confidence() {
624        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
625
626        let confidence = ConfidenceMetadata {
627            level: ConfidenceLevel::Verified,
628            limitations: vec![],
629            unavailable_features: vec![],
630        };
631
632        let meta = QueryMeta::new(Some("pattern".to_string()), Duration::from_millis(8))
633            .with_confidence(confidence);
634        let stats = Stats::new(10, 10);
635        let results = vec!["result1", "result2"];
636
637        let response = JsonResponse::new(meta, stats, results);
638
639        let json = serde_json::to_string(&response).unwrap();
640        assert!(json.contains("\"confidence\""));
641        assert!(json.contains("\"verified\""));
642    }
643}