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    /// Aggregate `WorkspaceIndexStatus` for the workspace this path
306    /// belongs to.
307    ///
308    /// Set only by [`Self::aggregate`]. The §1.4 contract for the
309    /// member-folder branch of `sqry/indexStatus`: when `path`
310    /// classifies as `Member`, the response carries the full
311    /// per-source-root status vector, the `missing_count` /
312    /// `building_count` / `ok_count` / `error_count` summary counters,
313    /// and the `generated_at` timestamp. Source / Excluded / Unknown
314    /// branches leave this field `None` so the wire shape stays
315    /// backwards-compatible.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub aggregate: Option<crate::workspace::WorkspaceIndexStatus>,
318
319    /// `true` when this status is a *partial* aggregate — at least one
320    /// source root is `missing` or `error`, so the workspace is not
321    /// fully indexed. Set only on the [`Self::aggregate`] path.
322    ///
323    /// Distinct from [`Self::stale`] (which carries the >24h freshness
324    /// bit on per-source-root responses); the two flags coexist in the
325    /// wire shape so consumers can disambiguate "old snapshot" from
326    /// "incomplete workspace".
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub partial: Option<bool>,
329}
330
331impl IndexStatus {
332    /// Create a new index status for non-existent index
333    #[must_use]
334    pub fn not_found() -> Self {
335        Self {
336            exists: false,
337            path: None,
338            created_at: None,
339            age_seconds: None,
340            symbol_count: None,
341            file_count: None,
342            languages: None,
343            supports_fuzzy: false,
344            supports_relations: false,
345            cross_language_relation_count: None,
346            symbol_counts_by_kind: None,
347            file_counts_by_language: None,
348            relation_counts_by_pair: None,
349            stale: None,
350            building: None,
351            build_age_seconds: None,
352            aggregate: None,
353            partial: None,
354        }
355    }
356
357    /// Create an index status indicating the path is explicitly excluded from
358    /// the logical workspace (per `LogicalWorkspace::classify` returning
359    /// `Classification::Excluded`).
360    ///
361    /// Wire shape mirrors [`Self::not_found`] (no graph data) but the `path`
362    /// field carries the canonical excluded path so the client can disambiguate
363    /// "outside workspace" from "inside but excluded". Callers must populate
364    /// `path` post-construction when context is available.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use sqry_core::json_response::IndexStatus;
370    /// let status = IndexStatus::excluded();
371    /// assert!(!status.exists);
372    /// ```
373    #[must_use]
374    pub fn excluded() -> Self {
375        Self {
376            exists: false,
377            path: None,
378            created_at: None,
379            age_seconds: None,
380            symbol_count: None,
381            file_count: None,
382            languages: None,
383            supports_fuzzy: false,
384            supports_relations: false,
385            cross_language_relation_count: None,
386            symbol_counts_by_kind: None,
387            file_counts_by_language: None,
388            relation_counts_by_pair: None,
389            stale: None,
390            building: None,
391            build_age_seconds: None,
392            aggregate: None,
393            partial: None,
394        }
395    }
396
397    /// Create an `IndexStatus` carrying the full aggregate
398    /// [`crate::workspace::WorkspaceIndexStatus`] for a workspace that
399    /// the requested path belongs to as a member folder (§1.4 of the
400    /// implementation plan, acceptance criterion 3).
401    ///
402    /// Member folders never own a snapshot themselves; they route
403    /// through their workspace's source roots. The response therefore
404    /// preserves the full per-source-root detail (`source_root_statuses`,
405    /// `missing_count`, `building_count`, `ok_count`, `error_count`,
406    /// `generated_at`) inside the [`Self::aggregate`] field, and
407    /// surfaces convenience summary projections through the existing
408    /// [`Self::exists`], [`Self::building`], and [`Self::age_seconds`]
409    /// scalars so simple consumers (e.g. `--json` formatters) can
410    /// render a one-line summary without re-walking the vector.
411    ///
412    /// - [`Self::exists`] is `true` iff every source root reports `Ok`
413    ///   (no missing, no errors). This mirrors the §1.4 contract that
414    ///   a member folder "is indexed" only when the workspace as a
415    ///   whole is.
416    /// - [`Self::path`] is set to the canonical member-folder path the
417    ///   caller supplied; consumers can disambiguate from
418    ///   [`Self::not_found`] (which omits `path`).
419    /// - [`Self::building`] is set when any source root has a
420    ///   build-lock present.
421    /// - [`Self::partial`] is set when any source root is `Missing`
422    ///   or `Error`, signalling that the aggregate is incomplete.
423    /// - [`Self::age_seconds`] is computed against `aggregate.generated_at`
424    ///   when at least one source root reports `Ok`.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// use std::path::PathBuf;
430    /// use sqry_core::json_response::IndexStatus;
431    /// use sqry_core::workspace::WorkspaceIndexStatus;
432    /// let aggregate = WorkspaceIndexStatus::from_source_root_statuses(Vec::new());
433    /// let status = IndexStatus::aggregate(PathBuf::from("/tmp/member"), aggregate);
434    /// assert_eq!(status.path.as_deref(), Some("/tmp/member"));
435    /// ```
436    #[must_use]
437    pub fn aggregate(
438        member_path: std::path::PathBuf,
439        aggregate: crate::workspace::WorkspaceIndexStatus,
440    ) -> Self {
441        let exists =
442            aggregate.error_count == 0 && aggregate.missing_count == 0 && aggregate.ok_count > 0;
443        let building = if aggregate.building_count > 0 {
444            Some(true)
445        } else {
446            None
447        };
448        let partial = if aggregate.missing_count > 0 || aggregate.error_count > 0 {
449            Some(true)
450        } else {
451            None
452        };
453        let age_seconds = if aggregate.ok_count > 0 {
454            let now_secs = std::time::SystemTime::now()
455                .duration_since(std::time::UNIX_EPOCH)
456                .map_or(0, |d| d.as_secs());
457            let generated_secs = aggregate
458                .generated_at
459                .duration_since(std::time::UNIX_EPOCH)
460                .map_or(now_secs, |d| d.as_secs());
461            Some(now_secs.saturating_sub(generated_secs))
462        } else {
463            None
464        };
465        Self {
466            exists,
467            path: Some(member_path.display().to_string()),
468            created_at: None,
469            age_seconds,
470            symbol_count: None,
471            file_count: None,
472            languages: None,
473            supports_fuzzy: false,
474            supports_relations: false,
475            cross_language_relation_count: None,
476            symbol_counts_by_kind: None,
477            file_counts_by_language: None,
478            relation_counts_by_pair: None,
479            // `stale` is reserved for the >24h freshness bit on
480            // per-source-root responses; on the aggregate path the
481            // partial-coverage flag lives in `partial`.
482            stale: None,
483            building,
484            build_age_seconds: None,
485            aggregate: Some(aggregate),
486            partial,
487        }
488    }
489
490    /// Create index status from metadata (builder pattern to avoid too many args)
491    #[must_use]
492    pub fn from_index(path: String, created_at: String, age_seconds: u64) -> IndexStatusBuilder {
493        IndexStatusBuilder {
494            path,
495            created_at,
496            age_seconds,
497            symbol_count: 0,
498            file_count: None,
499            languages: Vec::new(),
500            has_relations: false,
501            has_trigram: false,
502            cross_language_relation_count: 0,
503            symbol_counts_by_kind: None,
504            file_counts_by_language: None,
505            relation_counts_by_pair: None,
506            building: None,
507            build_age_seconds: None,
508        }
509    }
510}
511
512/// Builder for `IndexStatus` to avoid too many arguments
513pub struct IndexStatusBuilder {
514    path: String,
515    created_at: String,
516    age_seconds: u64,
517    symbol_count: usize,
518    file_count: Option<usize>,
519    languages: Vec<String>,
520    has_relations: bool,
521    has_trigram: bool,
522    cross_language_relation_count: usize,
523    symbol_counts_by_kind: Option<HashMap<String, usize>>,
524    file_counts_by_language: Option<HashMap<String, usize>>,
525    relation_counts_by_pair: Option<HashMap<String, usize>>,
526    building: Option<bool>,
527    build_age_seconds: Option<u64>,
528}
529
530impl IndexStatusBuilder {
531    /// Set the symbol count
532    #[must_use]
533    pub fn symbol_count(mut self, count: usize) -> Self {
534        self.symbol_count = count;
535        self
536    }
537
538    /// Set the file count
539    #[must_use]
540    pub fn file_count(mut self, count: usize) -> Self {
541        self.file_count = Some(count);
542        self
543    }
544
545    /// Set the file count as optional (None when unknown)
546    #[must_use]
547    pub fn file_count_opt(mut self, count: Option<usize>) -> Self {
548        self.file_count = count;
549        self
550    }
551
552    /// Set the languages list
553    #[must_use]
554    pub fn languages(mut self, langs: Vec<String>) -> Self {
555        self.languages = langs;
556        self
557    }
558
559    /// Set whether relations are present
560    #[must_use]
561    pub fn has_relations(mut self, value: bool) -> Self {
562        self.has_relations = value;
563        self
564    }
565
566    /// Set whether trigram index exists (for fuzzy search support)
567    #[must_use]
568    pub fn has_trigram(mut self, value: bool) -> Self {
569        self.has_trigram = value;
570        self
571    }
572
573    /// Set the cross-language relation count
574    #[must_use]
575    pub fn cross_language_relation_count(mut self, count: usize) -> Self {
576        self.cross_language_relation_count = count;
577        self
578    }
579
580    /// Set symbol counts grouped by kind (for tree view grouping)
581    #[must_use]
582    pub fn symbol_counts_by_kind(mut self, counts: HashMap<String, usize>) -> Self {
583        self.symbol_counts_by_kind = Some(counts);
584        self
585    }
586
587    /// Set file counts grouped by language (for tree view grouping)
588    #[must_use]
589    pub fn file_counts_by_language(mut self, counts: HashMap<String, usize>) -> Self {
590        self.file_counts_by_language = Some(counts);
591        self
592    }
593
594    /// Set relation counts grouped by language pair (for tree view grouping)
595    #[must_use]
596    pub fn relation_counts_by_pair(mut self, counts: HashMap<String, usize>) -> Self {
597        self.relation_counts_by_pair = Some(counts);
598        self
599    }
600
601    /// Set whether a build is currently in progress
602    #[must_use]
603    pub fn building(mut self, value: bool) -> Self {
604        self.building = Some(value);
605        self
606    }
607
608    /// Set the age of the build lock in seconds
609    #[must_use]
610    pub fn build_age_seconds(mut self, value: u64) -> Self {
611        self.build_age_seconds = Some(value);
612        self
613    }
614
615    /// Build the `IndexStatus`
616    #[must_use]
617    pub fn build(self) -> IndexStatus {
618        let stale = self.age_seconds > 86400; // >24 hours
619        IndexStatus {
620            exists: true,
621            path: Some(self.path),
622            created_at: Some(self.created_at),
623            age_seconds: Some(self.age_seconds),
624            symbol_count: Some(self.symbol_count),
625            file_count: self.file_count,
626            languages: Some(self.languages),
627            supports_fuzzy: self.has_trigram,
628            supports_relations: self.has_relations,
629            cross_language_relation_count: if self.cross_language_relation_count > 0 {
630                Some(self.cross_language_relation_count)
631            } else {
632                None
633            },
634            symbol_counts_by_kind: self.symbol_counts_by_kind,
635            file_counts_by_language: self.file_counts_by_language,
636            relation_counts_by_pair: self.relation_counts_by_pair,
637            stale: Some(stale),
638            building: self.building,
639            build_age_seconds: self.build_age_seconds,
640            aggregate: None,
641            partial: None,
642        }
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use serde_json;
650
651    #[test]
652    fn test_query_meta_serialization() {
653        let meta = QueryMeta::new(Some("handler".to_string()), Duration::from_millis(5));
654
655        let json = serde_json::to_string(&meta).unwrap();
656        assert!(json.contains("\"pattern\":\"handler\""));
657        assert!(json.contains("\"execution_time_ms\":5"));
658    }
659
660    #[test]
661    fn test_filters_empty() {
662        let filters = Filters::default();
663        assert!(filters.is_empty());
664
665        let json = serde_json::to_value(&filters).unwrap();
666        assert_eq!(json.as_object().unwrap().len(), 0);
667    }
668
669    #[test]
670    fn test_filters_with_values() {
671        let filters = Filters {
672            kind: Some("function".to_string()),
673            lang: Some("rust".to_string()),
674            ignore_case: true,
675            ..Default::default()
676        };
677
678        assert!(!filters.is_empty());
679        let json = serde_json::to_string(&filters).unwrap();
680        assert!(json.contains("\"kind\":\"function\""));
681        assert!(json.contains("\"lang\":\"rust\""));
682        assert!(json.contains("\"ignore_case\":true"));
683    }
684
685    #[test]
686    fn test_stats_truncation() {
687        let stats = Stats::new(100, 50);
688        assert_eq!(stats.total_matches, 100);
689        assert_eq!(stats.returned, 50);
690        assert!(stats.is_truncated);
691
692        let stats_not_truncated = Stats::new(50, 50);
693        assert!(!stats_not_truncated.is_truncated);
694    }
695
696    #[test]
697    fn test_json_response() {
698        let meta = QueryMeta::new(Some("test".to_string()), Duration::from_millis(10));
699        let stats = Stats::new(5, 5);
700        let results = vec!["result1", "result2"];
701
702        let response = JsonResponse::new(meta, stats, results);
703
704        let json = serde_json::to_string(&response).unwrap();
705        assert!(json.contains("\"query\""));
706        assert!(json.contains("\"stats\""));
707        assert!(json.contains("\"results\""));
708    }
709
710    #[test]
711    fn test_stream_event_partial() {
712        let event = StreamEvent::PartialResult {
713            result: "test_symbol",
714            score: 0.95,
715        };
716
717        let json = serde_json::to_string(&event).unwrap();
718        assert!(json.contains("\"event\":\"partial_result\""));
719        assert!(json.contains("\"score\":0.95"));
720    }
721
722    #[test]
723    fn test_stream_event_final() {
724        let stats = Stats::new(42, 20);
725        // Specify type parameter since FinalSummary carries no `T`
726        let event: StreamEvent<()> = StreamEvent::FinalSummary { stats };
727
728        let json = serde_json::to_string(&event).unwrap();
729        assert!(json.contains("\"event\":\"final_summary\""));
730        assert!(json.contains("\"total_matches\":42"));
731    }
732
733    #[test]
734    fn test_query_meta_with_confidence() {
735        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
736
737        let confidence = ConfidenceMetadata {
738            level: ConfidenceLevel::AstOnly,
739            limitations: vec!["Missing rust-analyzer".to_string()],
740            unavailable_features: vec!["Type inference".to_string()],
741        };
742
743        let meta = QueryMeta::new(Some("test_pattern".to_string()), Duration::from_millis(10))
744            .with_confidence(confidence.clone());
745
746        assert!(meta.confidence.is_some());
747        let meta_confidence = meta.confidence.unwrap();
748        assert_eq!(meta_confidence.level, ConfidenceLevel::AstOnly);
749        assert_eq!(meta_confidence.limitations.len(), 1);
750        assert_eq!(meta_confidence.unavailable_features.len(), 1);
751    }
752
753    #[test]
754    fn test_query_meta_confidence_serialization() {
755        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
756
757        let confidence = ConfidenceMetadata {
758            level: ConfidenceLevel::Partial,
759            limitations: vec!["Limited accuracy".to_string()],
760            unavailable_features: vec![],
761        };
762
763        let meta = QueryMeta::new(Some("search_pattern".to_string()), Duration::from_millis(5))
764            .with_confidence(confidence);
765
766        let json = serde_json::to_string(&meta).unwrap();
767        assert!(json.contains("\"confidence\""));
768        assert!(json.contains("\"partial\""));
769        assert!(json.contains("\"limitations\""));
770        assert!(json.contains("\"Limited accuracy\""));
771    }
772
773    #[test]
774    fn test_query_meta_without_confidence() {
775        let meta = QueryMeta::new(Some("test".to_string()), Duration::from_millis(5));
776
777        assert!(meta.confidence.is_none());
778
779        let json = serde_json::to_string(&meta).unwrap();
780        // None confidence should be omitted due to skip_serializing_if
781        assert!(!json.contains("\"confidence\""));
782    }
783
784    #[test]
785    fn test_json_response_with_confidence() {
786        use crate::confidence::{ConfidenceLevel, ConfidenceMetadata};
787
788        let confidence = ConfidenceMetadata {
789            level: ConfidenceLevel::Verified,
790            limitations: vec![],
791            unavailable_features: vec![],
792        };
793
794        let meta = QueryMeta::new(Some("pattern".to_string()), Duration::from_millis(8))
795            .with_confidence(confidence);
796        let stats = Stats::new(10, 10);
797        let results = vec!["result1", "result2"];
798
799        let response = JsonResponse::new(meta, stats, results);
800
801        let json = serde_json::to_string(&response).unwrap();
802        assert!(json.contains("\"confidence\""));
803        assert!(json.contains("\"verified\""));
804    }
805}