1use serde::{Deserialize, Serialize};
4
5use crate::{client::Opencode, error::OpencodeError};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum FileStatus {
15 Added,
17 Deleted,
19 Modified,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct FileInfo {
28 pub added: i64,
30 pub path: String,
32 pub removed: i64,
34 pub status: FileStatus,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct FileReadParams {
41 pub path: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct FileListParams {
48 pub path: String,
50}
51
52pub type FileStatusResponse = Vec<FileInfo>;
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum FileNodeType {
59 File,
61 Directory,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct FileNode {
68 pub name: String,
70 pub path: String,
72 pub absolute: String,
74 #[serde(rename = "type")]
76 pub node_type: FileNodeType,
77 pub ignored: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "lowercase")]
84pub enum FileContentType {
85 Text,
87 Binary,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93#[serde(rename_all = "camelCase")]
94pub struct FilePatchHunk {
95 pub old_start: f64,
97 pub old_lines: f64,
99 pub new_start: f64,
101 pub new_lines: f64,
103 pub lines: Vec<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109#[serde(rename_all = "camelCase")]
110pub struct FilePatch {
111 pub old_file_name: String,
113 pub new_file_name: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub old_header: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub new_header: Option<String>,
121 pub hunks: Vec<FilePatchHunk>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub index: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct FileContent {
132 #[serde(rename = "type")]
134 pub content_type: FileContentType,
135 pub content: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub diff: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub patch: Option<FilePatch>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub encoding: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub mime_type: Option<String>,
149}
150
151pub 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 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 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 pub async fn status(&self) -> Result<FileStatusResponse, OpencodeError> {
186 self.client.get("/file/status", None).await
187 }
188}
189
190#[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(¶ms).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 #[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 #[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 #[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 #[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 #[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 #[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}