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
21pub(crate) const GOOGLE_FOLDER_MIME_TYPE: &str = "application/vnd.google-apps.folder";
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
36pub struct Owner {
37 #[serde(
39 default,
40 skip_serializing_if = "Option::is_none",
41 rename = "displayName"
42 )]
43 pub display_name: Option<String>,
44 #[serde(
46 default,
47 skip_serializing_if = "Option::is_none",
48 rename = "emailAddress"
49 )]
50 pub email_address: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
56pub struct DriveFile {
57 pub id: String,
59 pub name: String,
61 #[serde(default, rename = "mimeType")]
64 pub mime_type: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub size: Option<String>,
69 #[serde(
75 default,
76 skip_serializing_if = "Option::is_none",
77 rename = "md5Checksum"
78 )]
79 pub md5_checksum: Option<String>,
80 #[serde(
83 default,
84 skip_serializing_if = "Option::is_none",
85 rename = "sha1Checksum"
86 )]
87 pub sha1_checksum: Option<String>,
88 #[serde(
91 default,
92 skip_serializing_if = "Option::is_none",
93 rename = "sha256Checksum"
94 )]
95 pub sha256_checksum: Option<String>,
96 #[serde(
98 default,
99 skip_serializing_if = "Option::is_none",
100 rename = "modifiedTime"
101 )]
102 pub modified_time: Option<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub parents: Vec<String>,
106 #[serde(
108 default,
109 skip_serializing_if = "Option::is_none",
110 rename = "webViewLink"
111 )]
112 pub web_view_link: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub owners: Vec<Owner>,
116 #[serde(default, skip_serializing_if = "Option::is_none", rename = "driveId")]
118 pub drive_id: Option<String>,
119 #[serde(
128 default,
129 skip_serializing_if = "Option::is_none",
130 rename = "exportLinks"
131 )]
132 pub export_links: Option<HashMap<String, String>>,
133}
134
135impl DriveFile {
136 #[must_use]
140 pub fn is_google_native(&self) -> bool {
141 self.mime_type.starts_with("application/vnd.google-apps.")
142 }
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
147pub struct FileListResponse {
148 #[serde(default)]
150 pub files: Vec<DriveFile>,
151 #[serde(
158 default,
159 skip_serializing_if = "Option::is_none",
160 rename = "nextPageToken"
161 )]
162 pub next_page_token: Option<String>,
163 #[serde(
169 default,
170 skip_serializing_if = "Option::is_none",
171 rename = "incompleteSearch"
172 )]
173 pub incomplete_search: Option<bool>,
174}
175
176impl JsonlSerialize for DriveFile {
177 fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
178 write_scalar_jsonl(self, out)
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
188pub struct DrivePermission {
189 #[serde(default)]
191 pub id: String,
192 #[serde(default, rename = "type")]
194 pub permission_type: String,
195 #[serde(default)]
200 pub role: String,
201 #[serde(
203 default,
204 skip_serializing_if = "Option::is_none",
205 rename = "emailAddress"
206 )]
207 pub email_address: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub domain: Option<String>,
211}
212
213#[cfg(test)]
214#[allow(clippy::unwrap_used, clippy::expect_used)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn file_list_response_deserializes_a_realistic_fixture() {
220 let json = serde_json::json!({
221 "files": [
222 {
223 "id": "f1",
224 "name": "report.pdf",
225 "mimeType": "application/pdf",
226 "size": "12345",
227 "md5Checksum": "5d41402abc4b2a76b9719d911017c592",
228 "sha1Checksum": "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
229 "sha256Checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
230 "modifiedTime": "2026-01-01T00:00:00.000Z",
231 "parents": ["folder1"],
232 "webViewLink": "https://drive.google.com/file/d/f1/view",
233 "owners": [{"displayName": "Alice", "emailAddress": "alice@example.com"}],
234 "driveId": "shared1",
235 },
236 ],
237 "nextPageToken": "page2",
238 "incompleteSearch": false,
239 });
240 let response: FileListResponse = serde_json::from_value(json).unwrap();
241 assert_eq!(response.files.len(), 1);
242 let file = &response.files[0];
243 assert_eq!(file.id, "f1");
244 assert_eq!(file.size.as_deref(), Some("12345"));
245 assert_eq!(
246 file.md5_checksum.as_deref(),
247 Some("5d41402abc4b2a76b9719d911017c592")
248 );
249 assert_eq!(
250 file.sha1_checksum.as_deref(),
251 Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")
252 );
253 assert_eq!(
254 file.sha256_checksum.as_deref(),
255 Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
256 );
257 assert_eq!(
258 file.owners[0].email_address.as_deref(),
259 Some("alice@example.com")
260 );
261 assert_eq!(file.drive_id.as_deref(), Some("shared1"));
262 assert_eq!(response.next_page_token.as_deref(), Some("page2"));
263 }
264
265 #[test]
266 fn checksums_are_none_when_absent() {
267 let json = serde_json::json!({"id": "f1", "name": "n"});
268 let file: DriveFile = serde_json::from_value(json).unwrap();
269 assert!(file.md5_checksum.is_none());
270 assert!(file.sha1_checksum.is_none());
271 assert!(file.sha256_checksum.is_none());
272 }
273
274 #[test]
275 fn size_round_trips_as_a_string_not_a_number() {
276 let file = DriveFile {
277 id: "f1".to_string(),
278 name: "n".to_string(),
279 size: Some("999".to_string()),
280 ..Default::default()
281 };
282 let value = serde_json::to_value(&file).unwrap();
283 assert_eq!(value["size"], serde_json::json!("999"));
284 }
285
286 #[test]
287 fn is_google_native_true_for_google_apps_mime_type() {
288 let file = DriveFile {
289 mime_type: "application/vnd.google-apps.document".to_string(),
290 ..Default::default()
291 };
292 assert!(file.is_google_native());
293 }
294
295 #[test]
296 fn is_google_native_false_for_ordinary_mime_type() {
297 let file = DriveFile {
298 mime_type: "application/pdf".to_string(),
299 ..Default::default()
300 };
301 assert!(!file.is_google_native());
302 }
303
304 #[test]
305 fn drive_file_deserializes_from_minimal_fields() {
306 let json = serde_json::json!({"id": "f1", "name": "n"});
307 let file: DriveFile = serde_json::from_value(json).unwrap();
308 assert_eq!(file.id, "f1");
309 assert_eq!(file.mime_type, "");
310 assert!(file.size.is_none());
311 assert!(file.export_links.is_none());
312 }
313
314 #[test]
315 fn deserializing_unmodeled_extra_field_still_succeeds() {
316 let json = serde_json::json!({
317 "id": "f1",
318 "name": "n",
319 "somethingNew": {"nested": true},
320 });
321 let file: DriveFile = serde_json::from_value(json).unwrap();
322 assert_eq!(file.id, "f1");
323 }
324
325 #[test]
326 fn export_links_parses_google_native_export_map() {
327 let json = serde_json::json!({
328 "id": "f1",
329 "name": "doc",
330 "mimeType": "application/vnd.google-apps.document",
331 "exportLinks": {
332 "text/markdown": "https://export.example/md",
333 "application/pdf": "https://export.example/pdf",
334 },
335 });
336 let file: DriveFile = serde_json::from_value(json).unwrap();
337 let links = file.export_links.unwrap();
338 assert_eq!(links.len(), 2);
339 assert!(links.contains_key("text/markdown"));
340 }
341
342 #[test]
343 fn drive_file_write_jsonl_emits_exactly_one_line() {
344 let file = DriveFile {
345 id: "f1".to_string(),
346 name: "n".to_string(),
347 ..Default::default()
348 };
349 let mut buf = Vec::new();
350 file.write_jsonl(&mut buf).unwrap();
351 let text = String::from_utf8(buf).unwrap();
352 assert_eq!(text.lines().count(), 1);
353 assert!(text.contains("f1"));
354 }
355}