Skip to main content

opencode_sdk_rs/resources/
file.rs

1//! File resource types and methods mirroring the JS SDK's `resources/file.ts`.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{client::Opencode, error::OpencodeError};
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11/// The status of a file in the project.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum FileStatus {
15    /// The file was newly added.
16    Added,
17    /// The file was deleted.
18    Deleted,
19    /// The file was modified.
20    Modified,
21}
22
23/// Information about a single file.
24///
25/// Named `FileInfo` instead of `File` to avoid collision with `std::fs::File`.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct FileInfo {
28    /// Number of lines added.
29    pub added: i64,
30    /// The file path.
31    pub path: String,
32    /// Number of lines removed.
33    pub removed: i64,
34    /// Current status of the file.
35    pub status: FileStatus,
36}
37
38/// Query parameters for reading a file.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct FileReadParams {
41    /// Path of the file to read.
42    pub path: String,
43}
44
45/// Query parameters for listing files.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct FileListParams {
48    /// Path to list files from.
49    pub path: String,
50}
51
52/// Response type for the file status endpoint.
53pub type FileStatusResponse = Vec<FileInfo>;
54
55/// The type of a node in a directory listing.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum FileNodeType {
59    /// A regular file.
60    File,
61    /// A directory.
62    Directory,
63}
64
65/// A node in a directory listing returned by `GET /file`.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct FileNode {
68    /// The file or directory name.
69    pub name: String,
70    /// Relative path from the project root.
71    pub path: String,
72    /// Absolute path on disk.
73    pub absolute: String,
74    /// Whether this node is a file or a directory.
75    #[serde(rename = "type")]
76    pub node_type: FileNodeType,
77    /// Whether this node is ignored (e.g. by `.gitignore`).
78    pub ignored: bool,
79}
80
81/// The content type of a file.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "lowercase")]
84pub enum FileContentType {
85    /// Plain text content.
86    Text,
87    /// Binary content (typically base64-encoded).
88    Binary,
89}
90
91/// A single hunk in a structured patch.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93#[serde(rename_all = "camelCase")]
94pub struct FilePatchHunk {
95    /// Starting line number in the old file.
96    pub old_start: f64,
97    /// Number of lines in the old file.
98    pub old_lines: f64,
99    /// Starting line number in the new file.
100    pub new_start: f64,
101    /// Number of lines in the new file.
102    pub new_lines: f64,
103    /// The diff lines (prefixed with `+`, `-`, or ` `).
104    pub lines: Vec<String>,
105}
106
107/// A structured patch describing changes between two file versions.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109#[serde(rename_all = "camelCase")]
110pub struct FilePatch {
111    /// The old file name.
112    pub old_file_name: String,
113    /// The new file name.
114    pub new_file_name: String,
115    /// The old file header (optional).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub old_header: Option<String>,
118    /// The new file header (optional).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub new_header: Option<String>,
121    /// The list of hunks in this patch.
122    pub hunks: Vec<FilePatchHunk>,
123    /// The index line (optional, e.g. git blob hashes).
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub index: Option<String>,
126}
127
128/// The content of a file as returned by the API.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct FileContent {
132    /// Whether the content is text or binary.
133    #[serde(rename = "type")]
134    pub content_type: FileContentType,
135    /// The file content (plain text or base64-encoded binary).
136    pub content: String,
137    /// A unified diff string (optional).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub diff: Option<String>,
140    /// A structured patch (optional).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub patch: Option<FilePatch>,
143    /// The encoding of the content (e.g. `"base64"` for binary files).
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub encoding: Option<String>,
146    /// The MIME type of the file (optional).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub mime_type: Option<String>,
149}
150
151// ---------------------------------------------------------------------------
152// Resource
153// ---------------------------------------------------------------------------
154
155/// Accessor for the `/file` endpoints.
156pub struct FileResource<'a> {
157    client: &'a Opencode,
158}
159
160impl<'a> FileResource<'a> {
161    pub(crate) const fn new(client: &'a Opencode) -> Self {
162        Self { client }
163    }
164
165    /// Read a file's content.
166    ///
167    /// `GET /file/content?path=<path>`
168    pub async fn read(&self, params: &FileReadParams) -> Result<FileContent, OpencodeError> {
169        self.client.get_with_query("/file/content", Some(params), None).await
170    }
171
172    /// List all files in the project directory tree.
173    ///
174    /// `GET /file?path=<path>`
175    pub async fn list(
176        &self,
177        params: Option<&FileListParams>,
178    ) -> Result<Vec<FileNode>, OpencodeError> {
179        self.client.get_with_query("/file", params, None).await
180    }
181
182    /// Get the status of all files in the project.
183    ///
184    /// `GET /file/status`
185    pub async fn status(&self) -> Result<FileStatusResponse, OpencodeError> {
186        self.client.get("/file/status", None).await
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Tests
192// ---------------------------------------------------------------------------
193
194#[cfg(test)]
195mod tests {
196    use serde_json;
197
198    use super::*;
199
200    #[test]
201    fn file_status_round_trip() {
202        for (variant, expected) in [
203            (FileStatus::Added, "\"added\""),
204            (FileStatus::Deleted, "\"deleted\""),
205            (FileStatus::Modified, "\"modified\""),
206        ] {
207            let json = serde_json::to_string(&variant).unwrap();
208            assert_eq!(json, expected);
209            let parsed: FileStatus = serde_json::from_str(&json).unwrap();
210            assert_eq!(parsed, variant);
211        }
212    }
213
214    #[test]
215    fn file_info_round_trip() {
216        let info = FileInfo {
217            added: 10,
218            path: "src/main.rs".to_string(),
219            removed: 3,
220            status: FileStatus::Modified,
221        };
222        let json = serde_json::to_string(&info).unwrap();
223        let parsed: FileInfo = serde_json::from_str(&json).unwrap();
224        assert_eq!(parsed, info);
225    }
226
227    #[test]
228    fn file_info_deserialize_from_js() {
229        let json = r#"{
230            "added": 5,
231            "path": "README.md",
232            "removed": 0,
233            "status": "added"
234        }"#;
235        let info: FileInfo = serde_json::from_str(json).unwrap();
236        assert_eq!(info.added, 5);
237        assert_eq!(info.path, "README.md");
238        assert_eq!(info.removed, 0);
239        assert_eq!(info.status, FileStatus::Added);
240    }
241
242    #[test]
243    fn file_read_params_round_trip() {
244        let params = FileReadParams { path: "src/lib.rs".to_string() };
245        let json = serde_json::to_string(&params).unwrap();
246        let parsed: FileReadParams = serde_json::from_str(&json).unwrap();
247        assert_eq!(parsed, params);
248    }
249
250    #[test]
251    fn file_status_response_round_trip() {
252        let response: FileStatusResponse = vec![
253            FileInfo { added: 1, path: "a.rs".to_string(), removed: 0, status: FileStatus::Added },
254            FileInfo {
255                added: 0,
256                path: "b.rs".to_string(),
257                removed: 10,
258                status: FileStatus::Deleted,
259            },
260        ];
261        let json = serde_json::to_string(&response).unwrap();
262        let parsed: FileStatusResponse = serde_json::from_str(&json).unwrap();
263        assert_eq!(parsed, response);
264    }
265
266    // -- FileNodeType --
267
268    #[test]
269    fn file_node_type_round_trip() {
270        for (variant, expected) in
271            [(FileNodeType::File, "\"file\""), (FileNodeType::Directory, "\"directory\"")]
272        {
273            let json = serde_json::to_string(&variant).unwrap();
274            assert_eq!(json, expected);
275            let parsed: FileNodeType = serde_json::from_str(&json).unwrap();
276            assert_eq!(parsed, variant);
277        }
278    }
279
280    // -- FileNode --
281
282    #[test]
283    fn file_node_round_trip() {
284        let node = FileNode {
285            name: "main.rs".to_string(),
286            path: "src/main.rs".to_string(),
287            absolute: "/home/user/project/src/main.rs".to_string(),
288            node_type: FileNodeType::File,
289            ignored: false,
290        };
291        let json = serde_json::to_string(&node).unwrap();
292        assert!(json.contains(r#""type":"file""#));
293        let parsed: FileNode = serde_json::from_str(&json).unwrap();
294        assert_eq!(parsed, node);
295    }
296
297    #[test]
298    fn file_node_deserialize_from_api() {
299        let json = r#"{
300            "name": "src",
301            "path": "src",
302            "absolute": "/home/user/project/src",
303            "type": "directory",
304            "ignored": true
305        }"#;
306        let node: FileNode = serde_json::from_str(json).unwrap();
307        assert_eq!(node.name, "src");
308        assert_eq!(node.node_type, FileNodeType::Directory);
309        assert!(node.ignored);
310    }
311
312    // -- FileContentType --
313
314    #[test]
315    fn file_content_type_round_trip() {
316        for (variant, expected) in
317            [(FileContentType::Text, "\"text\""), (FileContentType::Binary, "\"binary\"")]
318        {
319            let json = serde_json::to_string(&variant).unwrap();
320            assert_eq!(json, expected);
321            let parsed: FileContentType = serde_json::from_str(&json).unwrap();
322            assert_eq!(parsed, variant);
323        }
324    }
325
326    // -- FilePatchHunk --
327
328    #[test]
329    fn file_patch_hunk_round_trip() {
330        let hunk = FilePatchHunk {
331            old_start: 1.0,
332            old_lines: 3.0,
333            new_start: 1.0,
334            new_lines: 4.0,
335            lines: vec![
336                " fn main() {".to_string(),
337                "-    println!(\"old\");".to_string(),
338                "+    println!(\"new\");".to_string(),
339                "+    println!(\"extra\");".to_string(),
340                " }".to_string(),
341            ],
342        };
343        let json = serde_json::to_string(&hunk).unwrap();
344        assert!(json.contains(r#""oldStart":1"#));
345        assert!(json.contains(r#""newLines":4"#));
346        let parsed: FilePatchHunk = serde_json::from_str(&json).unwrap();
347        assert_eq!(parsed, hunk);
348    }
349
350    #[test]
351    fn file_patch_hunk_deserialize_camel_case() {
352        let json = r#"{
353            "oldStart": 10,
354            "oldLines": 2,
355            "newStart": 10,
356            "newLines": 3,
357            "lines": [" a", "-b", "+c", "+d"]
358        }"#;
359        let hunk: FilePatchHunk = serde_json::from_str(json).unwrap();
360        assert_eq!(hunk.old_start, 10.0);
361        assert_eq!(hunk.new_lines, 3.0);
362        assert_eq!(hunk.lines.len(), 4);
363    }
364
365    // -- FilePatch --
366
367    #[test]
368    fn file_patch_round_trip() {
369        let patch = FilePatch {
370            old_file_name: "a.rs".to_string(),
371            new_file_name: "a.rs".to_string(),
372            old_header: Some("old-header".to_string()),
373            new_header: Some("new-header".to_string()),
374            hunks: vec![FilePatchHunk {
375                old_start: 1.0,
376                old_lines: 1.0,
377                new_start: 1.0,
378                new_lines: 1.0,
379                lines: vec!["-old".to_string(), "+new".to_string()],
380            }],
381            index: Some("abc123..def456".to_string()),
382        };
383        let json = serde_json::to_string(&patch).unwrap();
384        assert!(json.contains(r#""oldFileName":"a.rs""#));
385        let parsed: FilePatch = serde_json::from_str(&json).unwrap();
386        assert_eq!(parsed, patch);
387    }
388
389    #[test]
390    fn file_patch_optional_fields_omitted() {
391        let patch = FilePatch {
392            old_file_name: "x.rs".to_string(),
393            new_file_name: "x.rs".to_string(),
394            old_header: None,
395            new_header: None,
396            hunks: vec![],
397            index: None,
398        };
399        let json = serde_json::to_string(&patch).unwrap();
400        assert!(!json.contains("oldHeader"));
401        assert!(!json.contains("newHeader"));
402        assert!(!json.contains("index"));
403        let parsed: FilePatch = serde_json::from_str(&json).unwrap();
404        assert_eq!(parsed, patch);
405    }
406
407    // -- FileContent --
408
409    #[test]
410    fn file_content_text_round_trip() {
411        let content = FileContent {
412            content_type: FileContentType::Text,
413            content: "fn main() {}".to_string(),
414            diff: Some("--- a\n+++ b".to_string()),
415            patch: None,
416            encoding: None,
417            mime_type: Some("text/x-rust".to_string()),
418        };
419        let json = serde_json::to_string(&content).unwrap();
420        assert!(json.contains(r#""type":"text""#));
421        assert!(json.contains(r#""mimeType":"text/x-rust""#));
422        assert!(!json.contains("encoding"));
423        let parsed: FileContent = serde_json::from_str(&json).unwrap();
424        assert_eq!(parsed, content);
425    }
426
427    #[test]
428    fn file_content_binary_round_trip() {
429        let content = FileContent {
430            content_type: FileContentType::Binary,
431            content: "aGVsbG8=".to_string(),
432            diff: None,
433            patch: None,
434            encoding: Some("base64".to_string()),
435            mime_type: Some("image/png".to_string()),
436        };
437        let json = serde_json::to_string(&content).unwrap();
438        assert!(json.contains(r#""type":"binary""#));
439        assert!(json.contains(r#""encoding":"base64""#));
440        let parsed: FileContent = serde_json::from_str(&json).unwrap();
441        assert_eq!(parsed, content);
442    }
443
444    #[test]
445    fn file_content_with_patch_round_trip() {
446        let content = FileContent {
447            content_type: FileContentType::Text,
448            content: "updated content".to_string(),
449            diff: Some("@@ -1 +1 @@".to_string()),
450            patch: Some(FilePatch {
451                old_file_name: "lib.rs".to_string(),
452                new_file_name: "lib.rs".to_string(),
453                old_header: None,
454                new_header: None,
455                hunks: vec![FilePatchHunk {
456                    old_start: 1.0,
457                    old_lines: 1.0,
458                    new_start: 1.0,
459                    new_lines: 1.0,
460                    lines: vec!["-old line".to_string(), "+new line".to_string()],
461                }],
462                index: None,
463            }),
464            encoding: None,
465            mime_type: None,
466        };
467        let json = serde_json::to_string(&content).unwrap();
468        let parsed: FileContent = serde_json::from_str(&json).unwrap();
469        assert_eq!(parsed, content);
470    }
471
472    #[test]
473    fn file_content_deserialize_minimal_from_api() {
474        let json = r#"{"type": "text", "content": "hello world"}"#;
475        let content: FileContent = serde_json::from_str(json).unwrap();
476        assert_eq!(content.content_type, FileContentType::Text);
477        assert_eq!(content.content, "hello world");
478        assert!(content.diff.is_none());
479        assert!(content.patch.is_none());
480        assert!(content.encoding.is_none());
481        assert!(content.mime_type.is_none());
482    }
483}