Skip to main content

ctx/
json.rs

1//! Machine-readable JSON output.
2//!
3//! All commands that support `--json` print exactly one JSON document to
4//! stdout, wrapped in a common envelope:
5//!
6//! ```json
7//! {
8//!   "ctx_version": "0.2.1",
9//!   "command": "query.find",
10//!   "generated_at": "2026-07-09T12:00:00Z",
11//!   "data": { ... }
12//! }
13//! ```
14//!
15//! Field names are snake_case throughout, and symbols are always emitted as
16//! [`SymbolRef`] objects (never bare strings). See `docs/json-output.md` for
17//! the full contract.
18
19use serde::Serialize;
20use time::format_description::well_known::Rfc3339;
21use time::OffsetDateTime;
22
23use crate::db::Symbol;
24use crate::error::Result;
25
26/// A reference to a symbol in JSON output.
27///
28/// This is the canonical shape used everywhere a symbol appears in `--json`
29/// payloads.
30#[derive(Debug, Clone, Serialize)]
31pub struct SymbolRef {
32    pub name: String,
33    pub qualified_name: Option<String>,
34    pub kind: String,
35    pub file: String,
36    pub line_start: i64,
37    pub line_end: i64,
38}
39
40impl From<&Symbol> for SymbolRef {
41    fn from(s: &Symbol) -> Self {
42        SymbolRef {
43            name: s.name.clone(),
44            qualified_name: s.qualified_name.clone(),
45            kind: s.kind.as_str().to_string(),
46            file: s.file_path.clone(),
47            line_start: s.line_start as i64,
48            line_end: s.line_end as i64,
49        }
50    }
51}
52
53impl SymbolRef {
54    /// Serialize into a `serde_json::Value`.
55    pub fn to_value(&self) -> serde_json::Value {
56        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
57    }
58}
59
60/// Wrap a command payload in the standard ctx JSON envelope.
61pub fn envelope(command: &str, data: serde_json::Value) -> serde_json::Value {
62    let generated_at = OffsetDateTime::now_utc()
63        .format(&Rfc3339)
64        .unwrap_or_default();
65    serde_json::json!({
66        "ctx_version": env!("CARGO_PKG_VERSION"),
67        "command": command,
68        "generated_at": generated_at,
69        "data": data,
70    })
71}
72
73/// Pretty-print the envelope for `command` to stdout.
74///
75/// In JSON mode this must be the only stdout output the command produces.
76pub fn emit(command: &str, data: serde_json::Value) -> Result<()> {
77    println!(
78        "{}",
79        serde_json::to_string_pretty(&envelope(command, data))?
80    );
81    Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::db::{SymbolKind, Visibility};
88
89    fn sample_symbol() -> Symbol {
90        Symbol {
91            id: "src/main.rs::main".to_string(),
92            file_path: "src/main.rs".to_string(),
93            name: "main".to_string(),
94            qualified_name: Some("crate::main".to_string()),
95            kind: SymbolKind::Function,
96            visibility: Visibility::Public,
97            signature: Some("fn main()".to_string()),
98            brief: None,
99            docstring: None,
100            line_start: 3,
101            line_end: 10,
102            col_start: 0,
103            col_end: 1,
104            parent_id: None,
105            source: None,
106        }
107    }
108
109    #[test]
110    fn test_envelope_shape() {
111        let value = envelope("query.find", serde_json::json!({"symbols": []}));
112        let obj = value.as_object().unwrap();
113
114        assert_eq!(obj.len(), 4);
115        assert_eq!(
116            obj.get("ctx_version").unwrap().as_str().unwrap(),
117            env!("CARGO_PKG_VERSION")
118        );
119        assert_eq!(obj.get("command").unwrap().as_str().unwrap(), "query.find");
120        assert_eq!(value["data"]["symbols"], serde_json::json!([]));
121
122        // generated_at must be a valid RFC3339 UTC timestamp.
123        let ts = obj.get("generated_at").unwrap().as_str().unwrap();
124        let parsed = OffsetDateTime::parse(ts, &Rfc3339);
125        assert!(parsed.is_ok(), "generated_at is not RFC3339: {}", ts);
126    }
127
128    #[test]
129    fn test_symbol_ref_snake_case_serialization() {
130        let symbol = sample_symbol();
131        let value = SymbolRef::from(&symbol).to_value();
132        let obj = value.as_object().unwrap();
133
134        // serde_json stores object keys sorted; compare as a sorted set.
135        let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
136        assert_eq!(
137            keys,
138            vec![
139                "file",
140                "kind",
141                "line_end",
142                "line_start",
143                "name",
144                "qualified_name"
145            ]
146        );
147        assert_eq!(value["name"], "main");
148        assert_eq!(value["qualified_name"], "crate::main");
149        assert_eq!(value["kind"], "function");
150        assert_eq!(value["file"], "src/main.rs");
151        assert_eq!(value["line_start"], 3);
152        assert_eq!(value["line_end"], 10);
153    }
154
155    #[test]
156    fn test_symbol_ref_null_qualified_name() {
157        let mut symbol = sample_symbol();
158        symbol.qualified_name = None;
159        let value = SymbolRef::from(&symbol).to_value();
160        assert!(value["qualified_name"].is_null());
161    }
162
163    #[test]
164    fn test_emitted_output_is_exactly_one_json_document() {
165        // Mirror what emit() prints and verify the stream contains exactly
166        // one JSON document.
167        let printed = format!(
168            "{}\n",
169            serde_json::to_string_pretty(&envelope(
170                "search",
171                serde_json::json!({"query": "q", "results": []})
172            ))
173            .unwrap()
174        );
175
176        let mut stream =
177            serde_json::Deserializer::from_str(&printed).into_iter::<serde_json::Value>();
178        let first = stream.next().expect("expected one document").unwrap();
179        assert_eq!(first["command"], "search");
180        assert!(stream.next().is_none(), "expected exactly one document");
181    }
182}