Skip to main content

opencode_sdk_rs/resources/
find.rs

1//! Find resource types and methods mirroring the JS SDK's `resources/find.ts`.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{client::Opencode, error::OpencodeError};
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11/// A position in a text document (zero-based line and character offset).
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct Position {
14    /// Zero-based character offset on the line.
15    pub character: i64,
16    /// Zero-based line number.
17    pub line: i64,
18}
19
20/// A range in a text document represented by a start and end position.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct Range {
23    /// The range's end position.
24    pub end: Position,
25    /// The range's start position.
26    pub start: Position,
27}
28
29/// The location of a symbol, including its URI and range.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct SymbolLocation {
32    /// The range within the document.
33    pub range: Range,
34    /// The URI of the document.
35    pub uri: String,
36}
37
38/// Information about a symbol found in the workspace.
39///
40/// Named `SymbolInfo` instead of `Symbol` to avoid collision with `std::symbol`.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct SymbolInfo {
43    /// The kind of symbol (e.g. function, class, variable).
44    pub kind: i64,
45    /// The location of the symbol.
46    pub location: SymbolLocation,
47    /// The name of the symbol.
48    pub name: String,
49}
50
51/// A text match value.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct TextMatch {
54    /// The matched text.
55    pub text: String,
56}
57
58/// A sub-match within a text search result.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct Submatch {
61    /// End byte offset of the sub-match.
62    pub end: i64,
63    /// The matched text.
64    #[serde(rename = "match")]
65    pub match_info: TextMatch,
66    /// Start byte offset of the sub-match.
67    pub start: i64,
68}
69
70/// Lines context for a text search result.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct Lines {
73    /// The text of the matched line(s).
74    pub text: String,
75}
76
77/// Path information for a text search result.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct PathInfo {
80    /// The file path as text.
81    pub text: String,
82}
83
84/// A single item in a text search response.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct FindTextResponseItem {
87    /// Absolute byte offset of the match in the file.
88    pub absolute_offset: i64,
89    /// One-based line number of the match.
90    pub line_number: i64,
91    /// The matched line(s).
92    pub lines: Lines,
93    /// The file path.
94    pub path: PathInfo,
95    /// Sub-matches within this result.
96    pub submatches: Vec<Submatch>,
97}
98
99/// Response from searching for files by name.
100pub type FindFilesResponse = Vec<String>;
101
102/// Response from searching for symbols.
103pub type FindSymbolsResponse = Vec<SymbolInfo>;
104
105/// Response from searching for text in files.
106pub type FindTextResponse = Vec<FindTextResponseItem>;
107
108// ---------------------------------------------------------------------------
109// Params
110// ---------------------------------------------------------------------------
111
112/// Query parameters for searching files by name.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct FindFilesParams {
115    /// The file name query.
116    pub query: String,
117}
118
119/// Query parameters for searching symbols.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct FindSymbolsParams {
122    /// The symbol name query.
123    pub query: String,
124}
125
126/// Query parameters for searching text in files.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct FindTextParams {
129    /// The text pattern to search for.
130    pub pattern: String,
131}
132
133// ---------------------------------------------------------------------------
134// Resource
135// ---------------------------------------------------------------------------
136
137/// Accessor for the `/find` endpoints.
138pub struct FindResource<'a> {
139    client: &'a Opencode,
140}
141
142impl<'a> FindResource<'a> {
143    pub(crate) const fn new(client: &'a Opencode) -> Self {
144        Self { client }
145    }
146
147    /// Search for files by name.
148    ///
149    /// `GET /find/file?query=<query>`
150    pub async fn files(
151        &self,
152        params: &FindFilesParams,
153    ) -> Result<FindFilesResponse, OpencodeError> {
154        self.client.get_with_query("/find/file", Some(params), None).await
155    }
156
157    /// Search for symbols by name.
158    ///
159    /// `GET /find/symbol?query=<query>`
160    pub async fn symbols(
161        &self,
162        params: &FindSymbolsParams,
163    ) -> Result<FindSymbolsResponse, OpencodeError> {
164        self.client.get_with_query("/find/symbol", Some(params), None).await
165    }
166
167    /// Search for text in files.
168    ///
169    /// `GET /find?pattern=<pattern>`
170    pub async fn text(&self, params: &FindTextParams) -> Result<FindTextResponse, OpencodeError> {
171        self.client.get_with_query("/find", Some(params), None).await
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Tests
177// ---------------------------------------------------------------------------
178
179#[cfg(test)]
180mod tests {
181    use serde_json;
182
183    use super::*;
184
185    #[test]
186    fn symbol_info_round_trip() {
187        let symbol = SymbolInfo {
188            kind: 12,
189            location: SymbolLocation {
190                range: Range {
191                    end: Position { character: 20, line: 10 },
192                    start: Position { character: 5, line: 10 },
193                },
194                uri: "file:///src/main.rs".to_owned(),
195            },
196            name: "my_function".to_owned(),
197        };
198
199        let json = serde_json::to_string(&symbol).unwrap();
200        let deserialized: SymbolInfo = serde_json::from_str(&json).unwrap();
201        assert_eq!(symbol, deserialized);
202
203        // Verify specific JSON structure
204        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
205        assert_eq!(value["kind"], 12);
206        assert_eq!(value["name"], "my_function");
207        assert_eq!(value["location"]["uri"], "file:///src/main.rs");
208        assert_eq!(value["location"]["range"]["start"]["line"], 10);
209        assert_eq!(value["location"]["range"]["start"]["character"], 5);
210        assert_eq!(value["location"]["range"]["end"]["character"], 20);
211    }
212
213    #[test]
214    fn find_text_response_item_round_trip() {
215        let item = FindTextResponseItem {
216            absolute_offset: 1024,
217            line_number: 42,
218            lines: Lines { text: "    let x = 42;".to_owned() },
219            path: PathInfo { text: "src/main.rs".to_owned() },
220            submatches: vec![Submatch {
221                end: 15,
222                match_info: TextMatch { text: "42".to_owned() },
223                start: 13,
224            }],
225        };
226
227        let json = serde_json::to_string(&item).unwrap();
228        let deserialized: FindTextResponseItem = serde_json::from_str(&json).unwrap();
229        assert_eq!(item, deserialized);
230
231        // Verify the `match` field is serialised with its original name
232        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
233        assert_eq!(value["absolute_offset"], 1024);
234        assert_eq!(value["line_number"], 42);
235        assert_eq!(value["lines"]["text"], "    let x = 42;");
236        assert_eq!(value["path"]["text"], "src/main.rs");
237        assert_eq!(value["submatches"][0]["match"]["text"], "42");
238        assert_eq!(value["submatches"][0]["start"], 13);
239        assert_eq!(value["submatches"][0]["end"], 15);
240    }
241
242    #[test]
243    fn find_text_response_item_deserialize_match_field() {
244        // Ensure we can deserialize from JSON where the field is called "match"
245        let json = r#"{
246            "absolute_offset": 0,
247            "line_number": 1,
248            "lines": { "text": "hello world" },
249            "path": { "text": "test.txt" },
250            "submatches": [{
251                "end": 5,
252                "match": { "text": "hello" },
253                "start": 0
254            }]
255        }"#;
256
257        let item: FindTextResponseItem = serde_json::from_str(json).unwrap();
258        assert_eq!(item.submatches[0].match_info.text, "hello");
259    }
260
261    #[test]
262    fn find_files_params_serialize() {
263        let params = FindFilesParams { query: "main.rs".to_owned() };
264        let json = serde_json::to_string(&params).unwrap();
265        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
266        assert_eq!(value["query"], "main.rs");
267    }
268
269    #[test]
270    fn find_symbols_params_serialize() {
271        let params = FindSymbolsParams { query: "MyStruct".to_owned() };
272        let json = serde_json::to_string(&params).unwrap();
273        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
274        assert_eq!(value["query"], "MyStruct");
275    }
276
277    #[test]
278    fn find_text_params_serialize() {
279        let params = FindTextParams { pattern: "TODO".to_owned() };
280        let json = serde_json::to_string(&params).unwrap();
281        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
282        assert_eq!(value["pattern"], "TODO");
283    }
284
285    #[test]
286    fn find_symbols_response_round_trip() {
287        let response: FindSymbolsResponse = vec![SymbolInfo {
288            kind: 5,
289            location: SymbolLocation {
290                range: Range {
291                    end: Position { character: 10, line: 0 },
292                    start: Position { character: 0, line: 0 },
293                },
294                uri: "file:///lib.rs".to_owned(),
295            },
296            name: "Foo".to_owned(),
297        }];
298
299        let json = serde_json::to_string(&response).unwrap();
300        let deserialized: FindSymbolsResponse = serde_json::from_str(&json).unwrap();
301        assert_eq!(response, deserialized);
302    }
303
304    #[test]
305    fn find_files_response_round_trip() {
306        let response: FindFilesResponse = vec!["src/main.rs".to_owned(), "src/lib.rs".to_owned()];
307
308        let json = serde_json::to_string(&response).unwrap();
309        let deserialized: FindFilesResponse = serde_json::from_str(&json).unwrap();
310        assert_eq!(response, deserialized);
311    }
312
313    // -- Edge cases --
314
315    #[test]
316    fn find_text_response_item_empty_submatches() {
317        let item = FindTextResponseItem {
318            absolute_offset: 0,
319            line_number: 1,
320            lines: Lines { text: "no matches here".to_owned() },
321            path: PathInfo { text: "test.txt".to_owned() },
322            submatches: vec![],
323        };
324        let json = serde_json::to_string(&item).unwrap();
325        let deserialized: FindTextResponseItem = serde_json::from_str(&json).unwrap();
326        assert_eq!(item, deserialized);
327        assert!(deserialized.submatches.is_empty());
328    }
329
330    #[test]
331    fn find_text_response_empty_vec() {
332        let response: FindTextResponse = vec![];
333        let json = serde_json::to_string(&response).unwrap();
334        assert_eq!(json, "[]");
335        let deserialized: FindTextResponse = serde_json::from_str(&json).unwrap();
336        assert_eq!(response, deserialized);
337    }
338
339    #[test]
340    fn find_files_response_empty() {
341        let response: FindFilesResponse = vec![];
342        let json = serde_json::to_string(&response).unwrap();
343        assert_eq!(json, "[]");
344        let deserialized: FindFilesResponse = serde_json::from_str(&json).unwrap();
345        assert_eq!(response, deserialized);
346    }
347
348    #[test]
349    fn find_symbols_response_empty() {
350        let response: FindSymbolsResponse = vec![];
351        let json = serde_json::to_string(&response).unwrap();
352        assert_eq!(json, "[]");
353        let deserialized: FindSymbolsResponse = serde_json::from_str(&json).unwrap();
354        assert_eq!(response, deserialized);
355    }
356}