Skip to main content

omni_dev/drive/
file_move.rs

1//! Drive file move — the security-gated capability this whole feature
2//! exists for ([ADR-0070](../../docs/adrs/adr-0070.md)).
3//!
4//! Adapts `crate::git::worktree_push`'s two-phase Plan/Execute shape, with
5//! one deliberate divergence from its doc comment's claim: `worktree_push`
6//! says "planning does not touch the network" because there's a local ACL
7//! cache (`refs/remotes/<remote>/<branch>`) to plan from — Drive has
8//! nothing analogous. What actually transfers, and matters here, is
9//! narrower: **planning never calls the one mutating endpoint**
10//! (`files.update`), so the plan a `--dry-run` shows is classified from the
11//! exact same `permissions.list` reads [`execute`] will act on.
12
13use 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/// Per-batch move options.
27#[derive(Debug, Clone)]
28pub struct MoveOptions {
29    /// The single shared destination folder every file in the batch moves
30    /// into. One destination per invocation — different files to different
31    /// destinations in one call is an explicit v1 non-goal.
32    pub dest_folder_id: String,
33    /// Allows a move that would grant new principals access.
34    pub allow_visibility_increase: bool,
35    /// Allows a move that would revoke existing principals' access.
36    pub allow_visibility_decrease: bool,
37    /// Allows a move across a My Drive / Shared Drive boundary.
38    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/// A CLI/log-friendly rendering of a [`VisibilityDiff`].
52///
53/// Principal display strings rather than the `Principal` enum. `None` on a
54/// [`MoveOutcome`] (rather than an always-present-but-possibly-empty
55/// report) means "nothing to report," so a clear `WouldMove`/`Moved`
56/// prints no visibility section at all.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
58pub struct VisibilityDiffReport {
59    /// Principals gaining access, as `"user:alice@example.com"` /
60    /// `"group:..."` / `"domain:..."` / `"anyone"`.
61    pub added: Vec<String>,
62    /// Principals losing access, same rendering as `added`.
63    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/// The planned (and, after [`execute`], final) batch.
76#[derive(Debug, Clone, Serialize)]
77pub struct MovePlan {
78    /// The shared destination every file in `files` was planned against.
79    pub dest_folder_id: String,
80    /// One entry per requested file id, in request order.
81    pub files: Vec<MoveOutcome>,
82}
83
84/// What happened (or, in a plan, would happen) to one file.
85#[derive(Debug, Clone, Serialize)]
86pub struct MoveOutcome {
87    /// The Drive file id acted on.
88    pub file_id: String,
89    /// The file's name at the time it was planned.
90    pub name: String,
91    /// The file's parent folder ids before this move — what [`execute`]
92    /// passes as `removeParents`. Also shown in `--dry-run` output as the
93    /// "from" side of the move.
94    pub current_parents: Vec<String>,
95    /// Whether the moved item is itself a folder — its own visibility
96    /// changes without its contents' visibility being evaluated (folder
97    /// moves don't recurse in v1). The CLI warns loudly on this.
98    pub is_folder: bool,
99    /// Whether this move crosses a My Drive / Shared Drive boundary.
100    pub crosses_drive_boundary: bool,
101    /// The visibility diff, when the move would change anything. `None`
102    /// when the diff is empty (nothing to report) — see the type's doc.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub visibility: Option<VisibilityDiffReport>,
105    /// The classification / outcome.
106    #[serde(flatten)]
107    pub result: MoveResult,
108}
109
110/// The per-file classification and outcome.
111///
112/// [`plan`] only ever produces `AlreadyInFolder` / `WouldMove` / `Blocked`
113/// / `Failed`; [`execute`] turns each `WouldMove` into `Moved` or `Failed`.
114#[derive(Debug, Clone, Serialize)]
115#[serde(tag = "status", rename_all = "kebab-case")]
116pub enum MoveResult {
117    /// The destination is already the file's sole parent — a no-op,
118    /// detected before any `permissions.list` call.
119    AlreadyInFolder,
120    /// Clear to move: no gate is blocking it.
121    WouldMove,
122    /// Refused by at least one safety gate.
123    Blocked {
124        /// Every gate that blocked this move — a move can fail more than
125        /// one simultaneously.
126        reasons: BlockReasons,
127    },
128    /// Moved.
129    Moved,
130    /// The plan step or the `files.update` call failed.
131    Failed {
132        /// The error, as displayed.
133        detail: String,
134    },
135}
136
137impl MoveResult {
138    /// Whether [`execute`] still has something to do for this outcome.
139    #[must_use]
140    pub const fn is_pending(&self) -> bool {
141        matches!(self, Self::WouldMove)
142    }
143
144    /// The `kind:` string [`record_attempt`] logs — matches this variant's
145    /// `#[serde(rename_all = "kebab-case")]` tag exactly, kept as a
146    /// hand-written match (not derived) since the request log's `status`
147    /// field is deliberately decoupled from the wire `#[serde(tag = ...)]`
148    /// shape.
149    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
160/// Plans a batch move, classifying every requested file against
161/// `opts.dest_folder_id`.
162///
163/// Validates the destination resolves to an actual folder once, up front,
164/// for the whole batch — a single clear error rather than N confusing
165/// per-file failures when it doesn't. A per-file failure during planning
166/// (a bad file id, a `permissions.list` error) does **not** abort the
167/// batch: it becomes that file's own [`MoveResult::Failed`] outcome, so one
168/// bad id can't take down an otherwise-valid batch.
169///
170/// **Contacts no mutating endpoint** — see the module doc.
171pub 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    // Pre-populate the cache with the destination's permissions — every
196    // file in the batch shares this same fetch, verified by wiremock
197    // call-count assertions in tests.
198    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
223/// Plans one file, catching every internal error into a [`MoveResult::Failed`]
224/// outcome rather than propagating it — the per-file isolation [`plan`]'s
225/// doc promises.
226async 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    // No-op short-circuit: the destination is already the file's sole
271    // parent. Checked before any `permissions.list` call — nothing to
272    // diff, since nothing would change.
273    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, &current_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
321/// Fetches `folder_id`'s permissions, reusing `cache` when another file in
322/// the same batch already fetched it (the destination, or a shared current
323/// parent) — the batch-wide `permissions.list` cache-hit behavior tested
324/// via wiremock call-count assertions.
325async 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/// Executes a [`MovePlan`], moving every file still `WouldMove`.
339///
340/// The rest (`AlreadyInFolder`/`Blocked`/already-`Failed`) pass through
341/// unchanged. A `files.update` failure becomes that file's own
342/// [`MoveResult::Failed`] — the batch continues regardless. Every outcome
343/// — moved, blocked, already-in-folder, or failed — is logged via
344/// [`request_log::record_drive_mutation`] from inside this function, not
345/// the CLI layer: "every move must be logged" needs to hold for every
346/// current and future caller, and a `Blocked` outcome makes no API call at
347/// all, so this is the only place that refusal is ever recorded.
348#[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
374/// Builds and writes the [`DriveMutationOutcome`] for one `move` attempt.
375fn 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        // Move is gated by the visibility diff, not the folder
395        // write-permission gate.
396        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    // ── plan: destination validation ────────────────────────────────
467
468    #[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    // ── plan: per-file outcomes ──────────────────────────────────────
508
509    #[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        // No `permissions` mock mounted at all for either id — asserting
532        // the short-circuit via absence would only prove "nothing 404'd";
533        // this instead asserts on a mock that would fail the test if
534        // called more than the expected zero times for the file, while
535        // the destination's own permissions fetch is expected exactly
536        // once (plan() always primes the cache with it up front,
537        // independent of any file's outcome).
538        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    // ── plan: batch behavior ─────────────────────────────────────────
722
723    #[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        // Shared parent and destination must each be fetched exactly once,
758        // despite two files sharing them.
759        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        // wiremock's .expect(1) assertions above are the real check —
785        // this just confirms both outcomes were classified successfully.
786        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        // The batch-level destination-permissions precondition must succeed
809        // so the failure below is isolated to f1's own permissions.list call.
810        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    // ── execute ──────────────────────────────────────────────────────
878
879    #[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    // ── MoveResult::log_status ───────────────────────────────────────
963
964    #[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}