1use std::collections::HashMap;
14use std::io::Write;
15
16use anyhow::Result;
17use serde::{Deserialize, Serialize};
18
19use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct Owner {
26 #[serde(
28 default,
29 skip_serializing_if = "Option::is_none",
30 rename = "displayName"
31 )]
32 pub display_name: Option<String>,
33 #[serde(
35 default,
36 skip_serializing_if = "Option::is_none",
37 rename = "emailAddress"
38 )]
39 pub email_address: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub struct DriveFile {
46 pub id: String,
48 pub name: String,
50 #[serde(default, rename = "mimeType")]
53 pub mime_type: String,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub size: Option<String>,
58 #[serde(
64 default,
65 skip_serializing_if = "Option::is_none",
66 rename = "md5Checksum"
67 )]
68 pub md5_checksum: Option<String>,
69 #[serde(
72 default,
73 skip_serializing_if = "Option::is_none",
74 rename = "sha1Checksum"
75 )]
76 pub sha1_checksum: Option<String>,
77 #[serde(
80 default,
81 skip_serializing_if = "Option::is_none",
82 rename = "sha256Checksum"
83 )]
84 pub sha256_checksum: Option<String>,
85 #[serde(
87 default,
88 skip_serializing_if = "Option::is_none",
89 rename = "modifiedTime"
90 )]
91 pub modified_time: Option<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub parents: Vec<String>,
95 #[serde(
97 default,
98 skip_serializing_if = "Option::is_none",
99 rename = "webViewLink"
100 )]
101 pub web_view_link: Option<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub owners: Vec<Owner>,
105 #[serde(default, skip_serializing_if = "Option::is_none", rename = "driveId")]
107 pub drive_id: Option<String>,
108 #[serde(
117 default,
118 skip_serializing_if = "Option::is_none",
119 rename = "exportLinks"
120 )]
121 pub export_links: Option<HashMap<String, String>>,
122}
123
124impl DriveFile {
125 #[must_use]
129 pub fn is_google_native(&self) -> bool {
130 self.mime_type.starts_with("application/vnd.google-apps.")
131 }
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
136pub struct FileListResponse {
137 #[serde(default)]
139 pub files: Vec<DriveFile>,
140 #[serde(
147 default,
148 skip_serializing_if = "Option::is_none",
149 rename = "nextPageToken"
150 )]
151 pub next_page_token: Option<String>,
152 #[serde(
158 default,
159 skip_serializing_if = "Option::is_none",
160 rename = "incompleteSearch"
161 )]
162 pub incomplete_search: Option<bool>,
163}
164
165impl JsonlSerialize for DriveFile {
166 fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
167 write_scalar_jsonl(self, out)
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
177pub struct DrivePermission {
178 #[serde(default)]
180 pub id: String,
181 #[serde(default, rename = "type")]
183 pub permission_type: String,
184 #[serde(default)]
189 pub role: String,
190 #[serde(
192 default,
193 skip_serializing_if = "Option::is_none",
194 rename = "emailAddress"
195 )]
196 pub email_address: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub domain: Option<String>,
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn file_list_response_deserializes_a_realistic_fixture() {
209 let json = serde_json::json!({
210 "files": [
211 {
212 "id": "f1",
213 "name": "report.pdf",
214 "mimeType": "application/pdf",
215 "size": "12345",
216 "md5Checksum": "5d41402abc4b2a76b9719d911017c592",
217 "sha1Checksum": "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
218 "sha256Checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
219 "modifiedTime": "2026-01-01T00:00:00.000Z",
220 "parents": ["folder1"],
221 "webViewLink": "https://drive.google.com/file/d/f1/view",
222 "owners": [{"displayName": "Alice", "emailAddress": "alice@example.com"}],
223 "driveId": "shared1",
224 },
225 ],
226 "nextPageToken": "page2",
227 "incompleteSearch": false,
228 });
229 let response: FileListResponse = serde_json::from_value(json).unwrap();
230 assert_eq!(response.files.len(), 1);
231 let file = &response.files[0];
232 assert_eq!(file.id, "f1");
233 assert_eq!(file.size.as_deref(), Some("12345"));
234 assert_eq!(
235 file.md5_checksum.as_deref(),
236 Some("5d41402abc4b2a76b9719d911017c592")
237 );
238 assert_eq!(
239 file.sha1_checksum.as_deref(),
240 Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")
241 );
242 assert_eq!(
243 file.sha256_checksum.as_deref(),
244 Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
245 );
246 assert_eq!(
247 file.owners[0].email_address.as_deref(),
248 Some("alice@example.com")
249 );
250 assert_eq!(file.drive_id.as_deref(), Some("shared1"));
251 assert_eq!(response.next_page_token.as_deref(), Some("page2"));
252 }
253
254 #[test]
255 fn checksums_are_none_when_absent() {
256 let json = serde_json::json!({"id": "f1", "name": "n"});
257 let file: DriveFile = serde_json::from_value(json).unwrap();
258 assert!(file.md5_checksum.is_none());
259 assert!(file.sha1_checksum.is_none());
260 assert!(file.sha256_checksum.is_none());
261 }
262
263 #[test]
264 fn size_round_trips_as_a_string_not_a_number() {
265 let file = DriveFile {
266 id: "f1".to_string(),
267 name: "n".to_string(),
268 size: Some("999".to_string()),
269 ..Default::default()
270 };
271 let value = serde_json::to_value(&file).unwrap();
272 assert_eq!(value["size"], serde_json::json!("999"));
273 }
274
275 #[test]
276 fn is_google_native_true_for_google_apps_mime_type() {
277 let file = DriveFile {
278 mime_type: "application/vnd.google-apps.document".to_string(),
279 ..Default::default()
280 };
281 assert!(file.is_google_native());
282 }
283
284 #[test]
285 fn is_google_native_false_for_ordinary_mime_type() {
286 let file = DriveFile {
287 mime_type: "application/pdf".to_string(),
288 ..Default::default()
289 };
290 assert!(!file.is_google_native());
291 }
292
293 #[test]
294 fn drive_file_deserializes_from_minimal_fields() {
295 let json = serde_json::json!({"id": "f1", "name": "n"});
296 let file: DriveFile = serde_json::from_value(json).unwrap();
297 assert_eq!(file.id, "f1");
298 assert_eq!(file.mime_type, "");
299 assert!(file.size.is_none());
300 assert!(file.export_links.is_none());
301 }
302
303 #[test]
304 fn deserializing_unmodeled_extra_field_still_succeeds() {
305 let json = serde_json::json!({
306 "id": "f1",
307 "name": "n",
308 "somethingNew": {"nested": true},
309 });
310 let file: DriveFile = serde_json::from_value(json).unwrap();
311 assert_eq!(file.id, "f1");
312 }
313
314 #[test]
315 fn export_links_parses_google_native_export_map() {
316 let json = serde_json::json!({
317 "id": "f1",
318 "name": "doc",
319 "mimeType": "application/vnd.google-apps.document",
320 "exportLinks": {
321 "text/markdown": "https://export.example/md",
322 "application/pdf": "https://export.example/pdf",
323 },
324 });
325 let file: DriveFile = serde_json::from_value(json).unwrap();
326 let links = file.export_links.unwrap();
327 assert_eq!(links.len(), 2);
328 assert!(links.contains_key("text/markdown"));
329 }
330
331 #[test]
332 fn drive_file_write_jsonl_emits_exactly_one_line() {
333 let file = DriveFile {
334 id: "f1".to_string(),
335 name: "n".to_string(),
336 ..Default::default()
337 };
338 let mut buf = Vec::new();
339 file.write_jsonl(&mut buf).unwrap();
340 let text = String::from_utf8(buf).unwrap();
341 assert_eq!(text.lines().count(), 1);
342 assert!(text.contains("f1"));
343 }
344}