Skip to main content

sqry_cli/output/
json.rs

1//! JSON output formatter
2//!
3//! Uses `DisplaySymbol` directly without deprecated Symbol type.
4
5use super::{
6    ContextLines, DisplaySymbol, Formatter, FormatterMetadata, OutputStreams, PreviewConfig,
7    PreviewExtractor, display_qualified_name,
8};
9use anyhow::Result;
10use serde::Serialize;
11use sqry_core::json_response::{JsonResponse, QueryMeta, Stats};
12use sqry_core::relations::{CallIdentityKind, CallIdentityMetadata};
13use sqry_core::workspace::NodeWithRepo;
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17/// JSON formatter for machine-readable output
18pub struct JsonFormatter {
19    preview_config: Option<PreviewConfig>,
20    workspace_root: PathBuf,
21}
22
23impl JsonFormatter {
24    /// Create new JSON formatter
25    #[must_use]
26    pub fn new() -> Self {
27        Self {
28            preview_config: None,
29            workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
30        }
31    }
32
33    /// Enable preview extraction
34    #[must_use]
35    pub fn with_preview(mut self, config: PreviewConfig, workspace_root: PathBuf) -> Self {
36        self.preview_config = Some(config);
37        self.workspace_root = workspace_root;
38        self
39    }
40
41    /// Format workspace query results with repository metadata.
42    ///
43    /// # Errors
44    /// Returns an error if writing to the output streams fails.
45    pub fn format_workspace(symbols: &[NodeWithRepo], streams: &mut OutputStreams) -> Result<()> {
46        #[derive(Serialize)]
47        struct Repo<'a> {
48            id: &'a str,
49            name: &'a str,
50            path: String,
51        }
52
53        #[derive(Serialize)]
54        struct WorkspaceResult<'a> {
55            repo: Repo<'a>,
56            #[serde(flatten)]
57            symbol: JsonSymbol,
58        }
59
60        let payload: Vec<_> = symbols
61            .iter()
62            .map(|entry| {
63                let info = &entry.match_info;
64                let mut metadata = HashMap::new();
65                if let Some(language) = &info.language {
66                    metadata.insert("__raw_language".to_string(), language.clone());
67                }
68                if info.is_static {
69                    metadata.insert("static".to_string(), "true".to_string());
70                }
71                let qualified_name = display_qualified_name(
72                    info.qualified_name.as_deref().unwrap_or(info.name.as_str()),
73                    info.kind.as_str(),
74                    info.language.as_deref(),
75                    info.is_static,
76                );
77                WorkspaceResult {
78                    repo: Repo {
79                        id: entry.repo_id.as_str(),
80                        name: &entry.repo_name,
81                        path: entry.repo_path.display().to_string(),
82                    },
83                    symbol: JsonSymbol {
84                        name: info.name.clone(),
85                        qualified_name,
86                        kind: info.kind.as_str().to_string(),
87                        file_path: info.file_path.display().to_string(),
88                        start_line: info.start_line as usize,
89                        start_column: info.start_column as usize,
90                        end_line: info.end_line as usize,
91                        end_column: info.end_column as usize,
92                        metadata,
93                        caller_identity: None,
94                        callee_identity: None,
95                        context: None,
96                    },
97                }
98            })
99            .collect();
100
101        let json = serde_json::to_string_pretty(&payload)?;
102        streams.write_result(&json)?;
103        Ok(())
104    }
105}
106
107impl Default for JsonFormatter {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl Formatter for JsonFormatter {
114    fn format(
115        &self,
116        symbols: &[DisplaySymbol],
117        metadata: Option<&FormatterMetadata>,
118        streams: &mut super::OutputStreams,
119    ) -> Result<()> {
120        // Convert symbols to JSON-friendly format
121        let mut preview_extractor = self
122            .preview_config
123            .as_ref()
124            .map(|config| PreviewExtractor::new(config.clone(), self.workspace_root.clone()));
125
126        let mut results: Vec<JsonSymbol> = Vec::with_capacity(symbols.len());
127
128        for display in symbols {
129            let mut json_symbol = JsonSymbol::from(display);
130            if let Some(ref mut extractor) = preview_extractor {
131                let ctx = extractor.extract(&display.file_path, display.start_line)?;
132                json_symbol.context = Some(JsonContext::from_context_lines(&ctx));
133            }
134            results.push(json_symbol);
135        }
136
137        // Build response based on whether we have metadata
138        let json = if let Some(meta) = metadata {
139            // Structured response with metadata
140            let query_meta = QueryMeta::new(meta.pattern.clone(), meta.execution_time)
141                .with_filters(meta.filters.clone());
142
143            let mut stats = Stats::new(meta.total_matches, results.len());
144            if let Some(age) = meta.index_age_seconds {
145                stats = stats.with_index_age(age);
146            }
147            // Add scope info if using ancestor index
148            if let Some(is_ancestor) = meta.used_ancestor_index {
149                stats = stats.with_scope_info(is_ancestor, meta.filtered_to.clone());
150            }
151
152            let response = JsonResponse::new(query_meta, stats, results);
153            serde_json::to_string_pretty(&response)?
154        } else {
155            // Legacy: plain array (for backward compatibility in non-search contexts)
156            serde_json::to_string_pretty(&results)?
157        };
158
159        streams.write_result(&json)?;
160        Ok(())
161    }
162}
163
164/// JSON-serializable symbol representation
165#[derive(Debug, Serialize)]
166pub struct JsonSymbol {
167    name: String,
168    qualified_name: String,
169    kind: String,
170    file_path: String,
171    start_line: usize,
172    start_column: usize,
173    end_line: usize,
174    end_column: usize,
175    #[serde(skip_serializing_if = "HashMap::is_empty")]
176    metadata: HashMap<String, String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    caller_identity: Option<JsonCallerIdentity>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    callee_identity: Option<JsonCallerIdentity>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub context: Option<JsonContext>,
183}
184
185#[derive(Debug, Serialize)]
186struct JsonCallerIdentity {
187    qualified: String,
188    simple: String,
189    namespace: Vec<String>,
190    method_kind: &'static str,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    receiver: Option<String>,
193}
194
195#[derive(Debug, Serialize)]
196pub struct JsonContext {
197    pub before: Vec<String>,
198    pub line: String,
199    pub after: Vec<String>,
200    pub line_numbers: JsonLineNumbers,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub error: Option<String>,
203}
204
205#[derive(Debug, Serialize)]
206pub struct JsonLineNumbers {
207    pub before: Vec<usize>,
208    pub matched: usize,
209    pub after: Vec<usize>,
210}
211
212impl JsonContext {
213    fn from_context_lines(ctx: &ContextLines) -> Self {
214        if ctx.is_error() {
215            return Self {
216                before: Vec::new(),
217                line: String::new(),
218                after: Vec::new(),
219                line_numbers: JsonLineNumbers::empty(),
220                error: ctx.error_message().map(ToOwned::to_owned),
221            };
222        }
223
224        Self {
225            before: ctx.before.iter().map(|l| l.content.clone()).collect(),
226            line: ctx.matched.content.clone(),
227            after: ctx.after.iter().map(|l| l.content.clone()).collect(),
228            line_numbers: JsonLineNumbers::from_context(ctx),
229            error: None,
230        }
231    }
232}
233
234impl JsonLineNumbers {
235    fn from_context(ctx: &ContextLines) -> Self {
236        Self {
237            before: ctx.before.iter().map(|l| l.line_number).collect(),
238            matched: ctx.matched.line_number,
239            after: ctx.after.iter().map(|l| l.line_number).collect(),
240        }
241    }
242
243    fn empty() -> Self {
244        Self {
245            before: Vec::new(),
246            matched: 0,
247            after: Vec::new(),
248        }
249    }
250}
251
252impl From<&DisplaySymbol> for JsonSymbol {
253    fn from(display: &DisplaySymbol) -> Self {
254        let language = display
255            .metadata
256            .get("__raw_language")
257            .map(std::string::String::as_str)
258            .filter(|language| *language != "unknown");
259        let is_static = display
260            .metadata
261            .get("static")
262            .is_some_and(|value| value == "true");
263
264        Self {
265            name: display.name.clone(),
266            qualified_name: display_qualified_name(
267                &display.qualified_name,
268                &display.kind,
269                language,
270                is_static,
271            ),
272            kind: display.kind.clone(),
273            file_path: display.file_path.display().to_string(),
274            start_line: display.start_line,
275            start_column: display.start_column,
276            end_line: display.end_line,
277            end_column: display.end_column,
278            metadata: display.metadata.clone(),
279            caller_identity: display
280                .caller_identity
281                .as_ref()
282                .map(JsonCallerIdentity::from),
283            callee_identity: display
284                .callee_identity
285                .as_ref()
286                .map(JsonCallerIdentity::from),
287            context: None,
288        }
289    }
290}
291
292impl From<&CallIdentityMetadata> for JsonCallerIdentity {
293    fn from(identity: &CallIdentityMetadata) -> Self {
294        Self {
295            qualified: identity.qualified.clone(),
296            simple: identity.simple.clone(),
297            namespace: identity.namespace.clone(),
298            method_kind: match identity.method_kind {
299                CallIdentityKind::Instance => "instance",
300                CallIdentityKind::Singleton => "singleton",
301                CallIdentityKind::SingletonClass => "singleton_class",
302            },
303            receiver: identity.receiver.clone(),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::output::TestOutputStreams;
312    use std::fs;
313    use std::path::PathBuf;
314    use tempfile::TempDir;
315
316    fn make_display_symbol(name: &str, kind: &str, path: PathBuf, line: usize) -> DisplaySymbol {
317        DisplaySymbol {
318            name: name.to_string(),
319            qualified_name: name.to_string(),
320            kind: kind.to_string(),
321            file_path: path,
322            start_line: line,
323            start_column: 1,
324            end_line: line,
325            end_column: 5,
326            metadata: HashMap::new(),
327            caller_identity: None,
328            callee_identity: None,
329        }
330    }
331
332    #[test]
333    fn test_json_symbol_from_display_symbol() {
334        let display = make_display_symbol(
335            "test_function",
336            "function",
337            PathBuf::from("src/test.rs"),
338            10,
339        );
340
341        let json_symbol = JsonSymbol::from(&display);
342        assert_eq!(json_symbol.name, "test_function");
343        assert_eq!(json_symbol.kind, "function");
344        assert_eq!(json_symbol.file_path, "src/test.rs");
345        assert_eq!(json_symbol.start_line, 10);
346    }
347
348    #[test]
349    fn test_json_formatter_empty() {
350        use crate::output::OutputStreams;
351
352        let formatter = JsonFormatter::new();
353        let symbols: Vec<DisplaySymbol> = Vec::new();
354        let mut streams = OutputStreams::new();
355
356        let result = formatter.format(&symbols, None, &mut streams);
357        assert!(result.is_ok());
358    }
359
360    #[test]
361    fn test_json_formatter_with_preview() {
362        let tmp = TempDir::new().unwrap();
363        let path = tmp.path().join("sample.rs");
364        fs::write(&path, "fn sample() {}\n").unwrap();
365
366        let sym = make_display_symbol("sample", "function", path, 1);
367
368        let formatter =
369            JsonFormatter::new().with_preview(PreviewConfig::new(1), tmp.path().to_path_buf());
370        let (test, mut streams) = TestOutputStreams::new();
371
372        formatter.format(&[sym], None, &mut streams).unwrap();
373
374        let out = test.stdout_string();
375        assert!(out.contains("\"context\""), "context missing: {out}");
376        assert!(out.contains("fn sample()"), "preview line missing: {out}");
377    }
378}