1use std::collections::HashMap;
14use std::time::{Duration, Instant};
15
16use anyhow::{Context, Result};
17use serde::Serialize;
18
19use crate::drive::client::DriveClient;
20use crate::drive::files_api::FilesApi;
21use crate::drive::permissions_api::PermissionsApi;
22use crate::drive::types::{DrivePermission, GOOGLE_FOLDER_MIME_TYPE};
23use crate::drive::visibility::{self, BlockReasons, MoveGateFlags, VisibilityDiff};
24use crate::request_log::{self, DriveMutationOutcome};
25
26#[derive(Debug, Clone)]
28pub struct MoveOptions {
29 pub dest_folder_id: String,
33 pub allow_visibility_increase: bool,
35 pub allow_visibility_decrease: bool,
37 pub allow_drive_boundary_crossing: bool,
39}
40
41impl MoveOptions {
42 fn gate_flags(&self) -> MoveGateFlags {
43 MoveGateFlags {
44 allow_visibility_increase: self.allow_visibility_increase,
45 allow_visibility_decrease: self.allow_visibility_decrease,
46 allow_drive_boundary_crossing: self.allow_drive_boundary_crossing,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
58pub struct VisibilityDiffReport {
59 pub added: Vec<String>,
62 pub removed: Vec<String>,
64}
65
66impl From<&VisibilityDiff> for VisibilityDiffReport {
67 fn from(diff: &VisibilityDiff) -> Self {
68 Self {
69 added: diff.added.iter().map(ToString::to_string).collect(),
70 removed: diff.removed.iter().map(ToString::to_string).collect(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct MovePlan {
78 pub dest_folder_id: String,
80 pub files: Vec<MoveOutcome>,
82}
83
84#[derive(Debug, Clone, Serialize)]
86pub struct MoveOutcome {
87 pub file_id: String,
89 pub name: String,
91 pub current_parents: Vec<String>,
95 pub is_folder: bool,
99 pub crosses_drive_boundary: bool,
101 #[serde(skip_serializing_if = "Option::is_none")]
104 pub visibility: Option<VisibilityDiffReport>,
105 #[serde(flatten)]
107 pub result: MoveResult,
108}
109
110#[derive(Debug, Clone, Serialize)]
115#[serde(tag = "status", rename_all = "kebab-case")]
116pub enum MoveResult {
117 AlreadyInFolder,
120 WouldMove,
122 Blocked {
124 reasons: BlockReasons,
127 },
128 Moved,
130 Failed {
132 detail: String,
134 },
135}
136
137impl MoveResult {
138 #[must_use]
140 pub const fn is_pending(&self) -> bool {
141 matches!(self, Self::WouldMove)
142 }
143
144 fn log_status(&self) -> &'static str {
150 match self {
151 Self::AlreadyInFolder => "already-in-folder",
152 Self::WouldMove => "would-move",
153 Self::Blocked { .. } => "blocked",
154 Self::Moved => "moved",
155 Self::Failed { .. } => "failed",
156 }
157 }
158}
159
160pub async fn plan(
172 client: &DriveClient,
173 file_ids: &[String],
174 opts: &MoveOptions,
175) -> Result<MovePlan> {
176 let files_api = FilesApi::new(client);
177 let permissions_api = PermissionsApi::new(client);
178
179 let dest_folder = files_api
180 .get_metadata(&opts.dest_folder_id)
181 .await
182 .with_context(|| {
183 format!(
184 "Failed to resolve destination folder '{}'",
185 opts.dest_folder_id
186 )
187 })?;
188 anyhow::ensure!(
189 dest_folder.mime_type == GOOGLE_FOLDER_MIME_TYPE,
190 "'{}' ({}) is not a folder — `drive move` can only move files into a folder",
191 dest_folder.name,
192 opts.dest_folder_id
193 );
194
195 let mut permission_cache: HashMap<String, Vec<DrivePermission>> = HashMap::new();
199 let dest_perms = permissions_api.list_all(&opts.dest_folder_id).await?;
200 permission_cache.insert(opts.dest_folder_id.clone(), dest_perms);
201
202 let mut files = Vec::with_capacity(file_ids.len());
203 for file_id in file_ids {
204 files.push(
205 plan_one(
206 &files_api,
207 &permissions_api,
208 &mut permission_cache,
209 file_id,
210 &dest_folder,
211 opts,
212 )
213 .await,
214 );
215 }
216
217 Ok(MovePlan {
218 dest_folder_id: opts.dest_folder_id.clone(),
219 files,
220 })
221}
222
223async fn plan_one(
227 files_api: &FilesApi<'_>,
228 permissions_api: &PermissionsApi<'_>,
229 permission_cache: &mut HashMap<String, Vec<DrivePermission>>,
230 file_id: &str,
231 dest_folder: &crate::drive::types::DriveFile,
232 opts: &MoveOptions,
233) -> MoveOutcome {
234 match plan_one_inner(
235 files_api,
236 permissions_api,
237 permission_cache,
238 file_id,
239 dest_folder,
240 opts,
241 )
242 .await
243 {
244 Ok(outcome) => outcome,
245 Err(err) => MoveOutcome {
246 file_id: file_id.to_string(),
247 name: String::new(),
248 current_parents: Vec::new(),
249 is_folder: false,
250 crosses_drive_boundary: false,
251 visibility: None,
252 result: MoveResult::Failed {
253 detail: err.to_string(),
254 },
255 },
256 }
257}
258
259async fn plan_one_inner(
260 files_api: &FilesApi<'_>,
261 permissions_api: &PermissionsApi<'_>,
262 permission_cache: &mut HashMap<String, Vec<DrivePermission>>,
263 file_id: &str,
264 dest_folder: &crate::drive::types::DriveFile,
265 opts: &MoveOptions,
266) -> Result<MoveOutcome> {
267 let file = files_api.get_metadata(file_id).await?;
268 let is_folder = file.mime_type == GOOGLE_FOLDER_MIME_TYPE;
269
270 if file.parents.len() == 1 && file.parents[0] == opts.dest_folder_id {
274 return Ok(MoveOutcome {
275 file_id: file_id.to_string(),
276 name: file.name,
277 current_parents: file.parents,
278 is_folder,
279 crosses_drive_boundary: false,
280 visibility: None,
281 result: MoveResult::AlreadyInFolder,
282 });
283 }
284
285 let file_perms = permissions_api.list_all(file_id).await?;
286
287 let mut current_parent_perms = Vec::new();
288 for parent_id in &file.parents {
289 current_parent_perms
290 .extend(fetch_cached(permissions_api, permission_cache, parent_id).await?);
291 }
292
293 let dest_perms = fetch_cached(permissions_api, permission_cache, &opts.dest_folder_id).await?;
294
295 let diff = visibility::diff_visibility(&file_perms, ¤t_parent_perms, &dest_perms);
296 let crosses_boundary = file.drive_id != dest_folder.drive_id;
297
298 let block_reasons = visibility::classify(&diff, crosses_boundary, opts.gate_flags());
299 let visibility_report = if diff.added.is_empty() && diff.removed.is_empty() {
300 None
301 } else {
302 Some(VisibilityDiffReport::from(&diff))
303 };
304
305 let result = match block_reasons {
306 Some(reasons) => MoveResult::Blocked { reasons },
307 None => MoveResult::WouldMove,
308 };
309
310 Ok(MoveOutcome {
311 file_id: file_id.to_string(),
312 name: file.name,
313 current_parents: file.parents,
314 is_folder,
315 crosses_drive_boundary: crosses_boundary,
316 visibility: visibility_report,
317 result,
318 })
319}
320
321async fn fetch_cached(
326 permissions_api: &PermissionsApi<'_>,
327 cache: &mut HashMap<String, Vec<DrivePermission>>,
328 folder_id: &str,
329) -> Result<Vec<DrivePermission>> {
330 if let Some(cached) = cache.get(folder_id) {
331 return Ok(cached.clone());
332 }
333 let perms = permissions_api.list_all(folder_id).await?;
334 cache.insert(folder_id.to_string(), perms.clone());
335 Ok(perms)
336}
337
338#[must_use]
349pub async fn execute(client: &DriveClient, plan: MovePlan) -> Vec<MoveOutcome> {
350 let files_api = FilesApi::new(client);
351 let dest_folder_id = plan.dest_folder_id;
352
353 let mut outcomes = Vec::with_capacity(plan.files.len());
354 for mut outcome in plan.files {
355 let started = Instant::now();
356 if outcome.result.is_pending() {
357 let remove_parents = outcome.current_parents.join(",");
358 outcome.result = match files_api
359 .move_to(&outcome.file_id, &dest_folder_id, &remove_parents)
360 .await
361 {
362 Ok(_) => MoveResult::Moved,
363 Err(err) => MoveResult::Failed {
364 detail: err.to_string(),
365 },
366 };
367 }
368 record_attempt(&outcome, started.elapsed());
369 outcomes.push(outcome);
370 }
371 outcomes
372}
373
374fn record_attempt(outcome: &MoveOutcome, duration: Duration) {
376 let error = match &outcome.result {
377 MoveResult::Failed { detail } => Some(detail.clone()),
378 _ => None,
379 };
380 let (added_principals, removed_principals) = outcome
381 .visibility
382 .as_ref()
383 .map(|v| (v.added.clone(), v.removed.clone()))
384 .unwrap_or_default();
385
386 request_log::record_drive_mutation(DriveMutationOutcome {
387 operation: "move",
388 file_id: outcome.file_id.clone(),
389 file_name: outcome.name.clone(),
390 status: outcome.result.log_status().to_string(),
391 added_principals,
392 removed_principals,
393 crosses_drive_boundary: outcome.crosses_drive_boundary,
394 resolved_folder_id: None,
397 decided_by_folder_id: None,
398 decided_by_depth: None,
399 error,
400 duration,
401 });
402}
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used, clippy::expect_used)]
406mod tests {
407 use super::*;
408 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
409 use crate::utils::secret::Secret;
410
411 fn test_credentials() -> DriveCredentials {
412 DriveCredentials {
413 client_id: "client-1".to_string(),
414 client_secret: Secret::new("secret-1"),
415 refresh_token: Secret::new("refresh-1"),
416 scope: DriveGrantedScopes::METADATA,
417 }
418 }
419
420 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
421 wiremock::Mock::given(wiremock::matchers::method("POST"))
422 .and(wiremock::matchers::path("/token"))
423 .respond_with(
424 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
425 "access_token": "test-token",
426 "expires_in": 3600,
427 })),
428 )
429 .mount(server)
430 .await;
431
432 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
433 crate::drive::client::test_support::replace_session(
434 &mut client,
435 &test_credentials(),
436 &format!("{}/token", server.uri()),
437 );
438 client
439 }
440
441 fn opts(dest: &str) -> MoveOptions {
442 MoveOptions {
443 dest_folder_id: dest.to_string(),
444 allow_visibility_increase: false,
445 allow_visibility_decrease: false,
446 allow_drive_boundary_crossing: false,
447 }
448 }
449
450 async fn mount_file(server: &wiremock::MockServer, id: &str, body: serde_json::Value) {
451 wiremock::Mock::given(wiremock::matchers::method("GET"))
452 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
453 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
454 .mount(server)
455 .await;
456 }
457
458 fn permissions_body(entries: &[(&str, &str)]) -> serde_json::Value {
459 serde_json::json!({
460 "permissions": entries.iter().map(|(id, email)| serde_json::json!({
461 "id": id, "type": "user", "role": "reader", "emailAddress": email,
462 })).collect::<Vec<_>>(),
463 })
464 }
465
466 #[tokio::test]
469 async fn plan_errors_when_destination_is_not_a_folder() {
470 let server = wiremock::MockServer::start().await;
471 let client = client_with_bootstrapped_token(&server).await;
472 wiremock::Mock::given(wiremock::matchers::method("GET"))
473 .and(wiremock::matchers::path("/drive/v3/files/dest1"))
474 .respond_with(
475 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
476 "id": "dest1", "name": "not-a-folder.txt", "mimeType": "text/plain",
477 })),
478 )
479 .mount(&server)
480 .await;
481
482 let err = plan(&client, &["f1".to_string()], &opts("dest1"))
483 .await
484 .unwrap_err();
485 assert!(err.to_string().contains("is not a folder"), "{err}");
486 }
487
488 #[tokio::test]
489 async fn plan_errors_when_destination_fetch_fails() {
490 let server = wiremock::MockServer::start().await;
491 let client = client_with_bootstrapped_token(&server).await;
492 wiremock::Mock::given(wiremock::matchers::method("GET"))
493 .and(wiremock::matchers::path("/drive/v3/files/dest1"))
494 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
495 .mount(&server)
496 .await;
497
498 let err = plan(&client, &["f1".to_string()], &opts("dest1"))
499 .await
500 .unwrap_err();
501 assert!(
502 err.to_string().contains("Failed to resolve destination"),
503 "{err}"
504 );
505 }
506
507 #[tokio::test]
510 async fn plan_detects_already_in_folder_without_any_permissions_call() {
511 let server = wiremock::MockServer::start().await;
512 let client = client_with_bootstrapped_token(&server).await;
513 wiremock::Mock::given(wiremock::matchers::method("GET"))
514 .and(wiremock::matchers::path("/drive/v3/files/dest1"))
515 .respond_with(
516 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
517 "id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
518 })),
519 )
520 .mount(&server)
521 .await;
522 wiremock::Mock::given(wiremock::matchers::method("GET"))
523 .and(wiremock::matchers::path("/drive/v3/files/f1"))
524 .respond_with(
525 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
526 "id": "f1", "name": "Already Here", "parents": ["dest1"],
527 })),
528 )
529 .mount(&server)
530 .await;
531 wiremock::Mock::given(wiremock::matchers::method("GET"))
539 .and(wiremock::matchers::path(
540 "/drive/v3/files/dest1/permissions",
541 ))
542 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
543 .expect(1)
544 .mount(&server)
545 .await;
546 wiremock::Mock::given(wiremock::matchers::method("GET"))
547 .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
548 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
549 .expect(0)
550 .mount(&server)
551 .await;
552
553 let result = plan(&client, &["f1".to_string()], &opts("dest1"))
554 .await
555 .unwrap();
556 assert!(matches!(
557 result.files[0].result,
558 MoveResult::AlreadyInFolder
559 ));
560 }
561
562 #[tokio::test]
563 async fn plan_detects_a_clear_move_with_no_visibility_change() {
564 let server = wiremock::MockServer::start().await;
565 let client = client_with_bootstrapped_token(&server).await;
566 mount_file(
567 &server,
568 "dest1",
569 serde_json::json!({"id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE}),
570 )
571 .await;
572 mount_file(
573 &server,
574 "f1",
575 serde_json::json!({"id": "f1", "name": "Report", "parents": ["src1"]}),
576 )
577 .await;
578 wiremock::Mock::given(wiremock::matchers::method("GET"))
579 .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
580 .respond_with(
581 wiremock::ResponseTemplate::new(200)
582 .set_body_json(permissions_body(&[("p1", "alice@example.com")])),
583 )
584 .mount(&server)
585 .await;
586 wiremock::Mock::given(wiremock::matchers::method("GET"))
587 .and(wiremock::matchers::path("/drive/v3/files/src1/permissions"))
588 .respond_with(
589 wiremock::ResponseTemplate::new(200)
590 .set_body_json(permissions_body(&[("p1", "alice@example.com")])),
591 )
592 .mount(&server)
593 .await;
594 wiremock::Mock::given(wiremock::matchers::method("GET"))
595 .and(wiremock::matchers::path(
596 "/drive/v3/files/dest1/permissions",
597 ))
598 .respond_with(
599 wiremock::ResponseTemplate::new(200)
600 .set_body_json(permissions_body(&[("p1", "alice@example.com")])),
601 )
602 .mount(&server)
603 .await;
604
605 let result = plan(&client, &["f1".to_string()], &opts("dest1"))
606 .await
607 .unwrap();
608 assert!(matches!(result.files[0].result, MoveResult::WouldMove));
609 assert!(result.files[0].visibility.is_none());
610 }
611
612 #[tokio::test]
613 async fn plan_blocks_a_visibility_increase_by_default_and_allows_when_opted_in() {
614 let server = wiremock::MockServer::start().await;
615 let client = client_with_bootstrapped_token(&server).await;
616 mount_file(
617 &server,
618 "dest1",
619 serde_json::json!({"id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE}),
620 )
621 .await;
622 mount_file(
623 &server,
624 "f1",
625 serde_json::json!({"id": "f1", "name": "Report", "parents": ["src1"]}),
626 )
627 .await;
628 wiremock::Mock::given(wiremock::matchers::method("GET"))
629 .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
630 .respond_with(
631 wiremock::ResponseTemplate::new(200)
632 .set_body_json(permissions_body(&[("p1", "alice@example.com")])),
633 )
634 .mount(&server)
635 .await;
636 wiremock::Mock::given(wiremock::matchers::method("GET"))
637 .and(wiremock::matchers::path("/drive/v3/files/src1/permissions"))
638 .respond_with(
639 wiremock::ResponseTemplate::new(200)
640 .set_body_json(permissions_body(&[("p1", "alice@example.com")])),
641 )
642 .mount(&server)
643 .await;
644 wiremock::Mock::given(wiremock::matchers::method("GET"))
645 .and(wiremock::matchers::path(
646 "/drive/v3/files/dest1/permissions",
647 ))
648 .respond_with(
649 wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[
650 ("p1", "alice@example.com"),
651 ("p2", "bob@example.com"),
652 ])),
653 )
654 .mount(&server)
655 .await;
656
657 let blocked = plan(&client, &["f1".to_string()], &opts("dest1"))
658 .await
659 .unwrap();
660 let MoveResult::Blocked { reasons } = &blocked.files[0].result else {
661 panic!("expected Blocked, got {:?}", blocked.files[0].result);
662 };
663 assert!(reasons.visibility_increase);
664 assert_eq!(
665 blocked.files[0].visibility.as_ref().unwrap().added,
666 vec!["user:bob@example.com".to_string()]
667 );
668
669 let mut allowed_opts = opts("dest1");
670 allowed_opts.allow_visibility_increase = true;
671 let allowed = plan(&client, &["f1".to_string()], &allowed_opts)
672 .await
673 .unwrap();
674 assert!(matches!(allowed.files[0].result, MoveResult::WouldMove));
675 }
676
677 #[tokio::test]
678 async fn plan_blocks_a_drive_boundary_crossing_even_with_no_visibility_change() {
679 let server = wiremock::MockServer::start().await;
680 let client = client_with_bootstrapped_token(&server).await;
681 mount_file(
682 &server,
683 "dest1",
684 serde_json::json!({
685 "id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
686 "driveId": "shared-drive-1",
687 }),
688 )
689 .await;
690 mount_file(
691 &server,
692 "f1",
693 serde_json::json!({"id": "f1", "name": "Report", "parents": ["src1"]}),
694 )
695 .await;
696 for path in [
697 "/drive/v3/files/f1/permissions",
698 "/drive/v3/files/src1/permissions",
699 "/drive/v3/files/dest1/permissions",
700 ] {
701 wiremock::Mock::given(wiremock::matchers::method("GET"))
702 .and(wiremock::matchers::path(path))
703 .respond_with(
704 wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])),
705 )
706 .mount(&server)
707 .await;
708 }
709
710 let result = plan(&client, &["f1".to_string()], &opts("dest1"))
711 .await
712 .unwrap();
713 let MoveResult::Blocked { reasons } = &result.files[0].result else {
714 panic!("expected Blocked, got {:?}", result.files[0].result);
715 };
716 assert!(reasons.drive_boundary_crossing);
717 assert!(!reasons.visibility_increase);
718 assert!(!reasons.visibility_decrease);
719 }
720
721 #[tokio::test]
724 async fn plan_caches_dest_and_shared_current_parent_fetches_across_the_batch() {
725 let server = wiremock::MockServer::start().await;
726 let client = client_with_bootstrapped_token(&server).await;
727 mount_file(
728 &server,
729 "dest1",
730 serde_json::json!({"id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE}),
731 )
732 .await;
733 mount_file(
734 &server,
735 "f1",
736 serde_json::json!({"id": "f1", "name": "A", "parents": ["shared_parent"]}),
737 )
738 .await;
739 mount_file(
740 &server,
741 "f2",
742 serde_json::json!({"id": "f2", "name": "B", "parents": ["shared_parent"]}),
743 )
744 .await;
745 wiremock::Mock::given(wiremock::matchers::method("GET"))
746 .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
747 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
748 .expect(1)
749 .mount(&server)
750 .await;
751 wiremock::Mock::given(wiremock::matchers::method("GET"))
752 .and(wiremock::matchers::path("/drive/v3/files/f2/permissions"))
753 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
754 .expect(1)
755 .mount(&server)
756 .await;
757 wiremock::Mock::given(wiremock::matchers::method("GET"))
760 .and(wiremock::matchers::path(
761 "/drive/v3/files/shared_parent/permissions",
762 ))
763 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
764 .expect(1)
765 .mount(&server)
766 .await;
767 wiremock::Mock::given(wiremock::matchers::method("GET"))
768 .and(wiremock::matchers::path(
769 "/drive/v3/files/dest1/permissions",
770 ))
771 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
772 .expect(1)
773 .mount(&server)
774 .await;
775
776 let result = plan(
777 &client,
778 &["f1".to_string(), "f2".to_string()],
779 &opts("dest1"),
780 )
781 .await
782 .unwrap();
783 assert_eq!(result.files.len(), 2);
784 assert!(result
787 .files
788 .iter()
789 .all(|f| matches!(f.result, MoveResult::WouldMove)));
790 }
791
792 #[tokio::test]
793 async fn plan_a_permissions_list_failure_yields_failed_not_an_empty_set_fallback() {
794 let server = wiremock::MockServer::start().await;
795 let client = client_with_bootstrapped_token(&server).await;
796 mount_file(
797 &server,
798 "dest1",
799 serde_json::json!({"id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE}),
800 )
801 .await;
802 mount_file(
803 &server,
804 "f1",
805 serde_json::json!({"id": "f1", "name": "A", "parents": ["src1"]}),
806 )
807 .await;
808 wiremock::Mock::given(wiremock::matchers::method("GET"))
811 .and(wiremock::matchers::path(
812 "/drive/v3/files/dest1/permissions",
813 ))
814 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
815 .mount(&server)
816 .await;
817 wiremock::Mock::given(wiremock::matchers::method("GET"))
818 .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
819 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
820 .mount(&server)
821 .await;
822
823 let result = plan(&client, &["f1".to_string()], &opts("dest1"))
824 .await
825 .unwrap();
826 assert!(
827 matches!(result.files[0].result, MoveResult::Failed { .. }),
828 "expected Failed, got {:?} — a permissions.list failure must never silently \
829 degrade to an empty-set fallback",
830 result.files[0].result
831 );
832 }
833
834 #[tokio::test]
835 async fn plan_one_bad_file_id_fails_only_that_file_batch_continues() {
836 let server = wiremock::MockServer::start().await;
837 let client = client_with_bootstrapped_token(&server).await;
838 mount_file(
839 &server,
840 "dest1",
841 serde_json::json!({"id": "dest1", "name": "Dest", "mimeType": GOOGLE_FOLDER_MIME_TYPE}),
842 )
843 .await;
844 wiremock::Mock::given(wiremock::matchers::method("GET"))
845 .and(wiremock::matchers::path(
846 "/drive/v3/files/dest1/permissions",
847 ))
848 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(permissions_body(&[])))
849 .mount(&server)
850 .await;
851 wiremock::Mock::given(wiremock::matchers::method("GET"))
852 .and(wiremock::matchers::path("/drive/v3/files/missing"))
853 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
854 .mount(&server)
855 .await;
856 mount_file(
857 &server,
858 "f1",
859 serde_json::json!({"id": "f1", "name": "A", "parents": ["dest1"]}),
860 )
861 .await;
862
863 let result = plan(
864 &client,
865 &["missing".to_string(), "f1".to_string()],
866 &opts("dest1"),
867 )
868 .await
869 .unwrap();
870 assert!(matches!(result.files[0].result, MoveResult::Failed { .. }));
871 assert!(matches!(
872 result.files[1].result,
873 MoveResult::AlreadyInFolder
874 ));
875 }
876
877 #[tokio::test]
880 async fn execute_moves_a_would_move_outcome_and_leaves_others_unchanged() {
881 let server = wiremock::MockServer::start().await;
882 let client = client_with_bootstrapped_token(&server).await;
883 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
884 .and(wiremock::matchers::path("/drive/v3/files/f1"))
885 .and(wiremock::matchers::query_param("addParents", "dest1"))
886 .and(wiremock::matchers::query_param("removeParents", "src1"))
887 .respond_with(
888 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
889 "id": "f1", "name": "A", "parents": ["dest1"],
890 })),
891 )
892 .expect(1)
893 .mount(&server)
894 .await;
895
896 let move_plan = MovePlan {
897 dest_folder_id: "dest1".to_string(),
898 files: vec![
899 MoveOutcome {
900 file_id: "f1".to_string(),
901 name: "A".to_string(),
902 current_parents: vec!["src1".to_string()],
903 is_folder: false,
904 crosses_drive_boundary: false,
905 visibility: None,
906 result: MoveResult::WouldMove,
907 },
908 MoveOutcome {
909 file_id: "f2".to_string(),
910 name: "B".to_string(),
911 current_parents: vec!["dest1".to_string()],
912 is_folder: false,
913 crosses_drive_boundary: false,
914 visibility: None,
915 result: MoveResult::AlreadyInFolder,
916 },
917 ],
918 };
919
920 let outcomes = execute(&client, move_plan).await;
921 assert!(matches!(outcomes[0].result, MoveResult::Moved));
922 assert!(matches!(outcomes[1].result, MoveResult::AlreadyInFolder));
923 }
924
925 #[tokio::test]
926 async fn execute_records_a_failed_move_to_call() {
927 let server = wiremock::MockServer::start().await;
928 let client = client_with_bootstrapped_token(&server).await;
929 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
930 .and(wiremock::matchers::path("/drive/v3/files/f1"))
931 .respond_with(
932 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
933 "error": {
934 "message": "Insufficient Permission",
935 "errors": [{"reason": "insufficientPermissions"}],
936 }
937 })),
938 )
939 .mount(&server)
940 .await;
941
942 let move_plan = MovePlan {
943 dest_folder_id: "dest1".to_string(),
944 files: vec![MoveOutcome {
945 file_id: "f1".to_string(),
946 name: "A".to_string(),
947 current_parents: vec!["src1".to_string()],
948 is_folder: false,
949 crosses_drive_boundary: false,
950 visibility: None,
951 result: MoveResult::WouldMove,
952 }],
953 };
954
955 let outcomes = execute(&client, move_plan).await;
956 let MoveResult::Failed { detail } = &outcomes[0].result else {
957 panic!("expected Failed, got {:?}", outcomes[0].result);
958 };
959 assert!(detail.contains("drive auth login --write"), "{detail}");
960 }
961
962 #[test]
965 fn log_status_matches_the_serde_tag_for_every_variant() {
966 assert_eq!(
967 MoveResult::AlreadyInFolder.log_status(),
968 "already-in-folder"
969 );
970 assert_eq!(MoveResult::WouldMove.log_status(), "would-move");
971 assert_eq!(
972 MoveResult::Blocked {
973 reasons: BlockReasons::default()
974 }
975 .log_status(),
976 "blocked"
977 );
978 assert_eq!(MoveResult::Moved.log_status(), "moved");
979 assert_eq!(
980 MoveResult::Failed {
981 detail: "x".to_string()
982 }
983 .log_status(),
984 "failed"
985 );
986 }
987}