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