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;
23use crate::drive::visibility::{self, BlockReasons, MoveGateFlags, VisibilityDiff};
24use crate::request_log::{self, DriveMutationOutcome};
25
26/// MIME type marking a Drive folder. Deliberately a private, local copy of
27/// `crate::cli::drive::read::GOOGLE_FOLDER` rather than a shared import:
28/// this is an engine module, and depending on a CLI-layer module for a
29/// stable, well-known Drive constant would invert the usual CLI-depends-on-
30/// engine direction.
31const GOOGLE_FOLDER_MIME_TYPE: &str = "application/vnd.google-apps.folder";
32
33/// Per-batch move options.
34#[derive(Debug, Clone)]
35pub struct MoveOptions {
36    /// The single shared destination folder every file in the batch moves
37    /// into. One destination per invocation — different files to different
38    /// destinations in one call is an explicit v1 non-goal.
39    pub dest_folder_id: String,
40    /// Allows a move that would grant new principals access.
41    pub allow_visibility_increase: bool,
42    /// Allows a move that would revoke existing principals' access.
43    pub allow_visibility_decrease: bool,
44    /// Allows a move across a My Drive / Shared Drive boundary.
45    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/// A CLI/log-friendly rendering of a [`VisibilityDiff`].
59///
60/// Principal display strings rather than the `Principal` enum. `None` on a
61/// [`MoveOutcome`] (rather than an always-present-but-possibly-empty
62/// report) means "nothing to report," so a clear `WouldMove`/`Moved`
63/// prints no visibility section at all.
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
65pub struct VisibilityDiffReport {
66    /// Principals gaining access, as `"user:alice@example.com"` /
67    /// `"group:..."` / `"domain:..."` / `"anyone"`.
68    pub added: Vec<String>,
69    /// Principals losing access, same rendering as `added`.
70    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/// The planned (and, after [`execute`], final) batch.
83#[derive(Debug, Clone, Serialize)]
84pub struct MovePlan {
85    /// The shared destination every file in `files` was planned against.
86    pub dest_folder_id: String,
87    /// One entry per requested file id, in request order.
88    pub files: Vec<MoveOutcome>,
89}
90
91/// What happened (or, in a plan, would happen) to one file.
92#[derive(Debug, Clone, Serialize)]
93pub struct MoveOutcome {
94    /// The Drive file id acted on.
95    pub file_id: String,
96    /// The file's name at the time it was planned.
97    pub name: String,
98    /// The file's parent folder ids before this move — what [`execute`]
99    /// passes as `removeParents`. Also shown in `--dry-run` output as the
100    /// "from" side of the move.
101    pub current_parents: Vec<String>,
102    /// Whether the moved item is itself a folder — its own visibility
103    /// changes without its contents' visibility being evaluated (folder
104    /// moves don't recurse in v1). The CLI warns loudly on this.
105    pub is_folder: bool,
106    /// Whether this move crosses a My Drive / Shared Drive boundary.
107    pub crosses_drive_boundary: bool,
108    /// The visibility diff, when the move would change anything. `None`
109    /// when the diff is empty (nothing to report) — see the type's doc.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub visibility: Option<VisibilityDiffReport>,
112    /// The classification / outcome.
113    #[serde(flatten)]
114    pub result: MoveResult,
115}
116
117/// The per-file classification and outcome.
118///
119/// [`plan`] only ever produces `AlreadyInFolder` / `WouldMove` / `Blocked`
120/// / `Failed`; [`execute`] turns each `WouldMove` into `Moved` or `Failed`.
121#[derive(Debug, Clone, Serialize)]
122#[serde(tag = "status", rename_all = "kebab-case")]
123pub enum MoveResult {
124    /// The destination is already the file's sole parent — a no-op,
125    /// detected before any `permissions.list` call.
126    AlreadyInFolder,
127    /// Clear to move: no gate is blocking it.
128    WouldMove,
129    /// Refused by at least one safety gate.
130    Blocked {
131        /// Every gate that blocked this move — a move can fail more than
132        /// one simultaneously.
133        reasons: BlockReasons,
134    },
135    /// Moved.
136    Moved,
137    /// The plan step or the `files.update` call failed.
138    Failed {
139        /// The error, as displayed.
140        detail: String,
141    },
142}
143
144impl MoveResult {
145    /// Whether [`execute`] still has something to do for this outcome.
146    #[must_use]
147    pub const fn is_pending(&self) -> bool {
148        matches!(self, Self::WouldMove)
149    }
150
151    /// The `kind:` string [`record_attempt`] logs — matches this variant's
152    /// `#[serde(rename_all = "kebab-case")]` tag exactly, kept as a
153    /// hand-written match (not derived) since the request log's `status`
154    /// field is deliberately decoupled from the wire `#[serde(tag = ...)]`
155    /// shape.
156    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
167/// Plans a batch move, classifying every requested file against
168/// `opts.dest_folder_id`.
169///
170/// Validates the destination resolves to an actual folder once, up front,
171/// for the whole batch — a single clear error rather than N confusing
172/// per-file failures when it doesn't. A per-file failure during planning
173/// (a bad file id, a `permissions.list` error) does **not** abort the
174/// batch: it becomes that file's own [`MoveResult::Failed`] outcome, so one
175/// bad id can't take down an otherwise-valid batch.
176///
177/// **Contacts no mutating endpoint** — see the module doc.
178pub 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    // Pre-populate the cache with the destination's permissions — every
203    // file in the batch shares this same fetch, verified by wiremock
204    // call-count assertions in tests.
205    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
230/// Plans one file, catching every internal error into a [`MoveResult::Failed`]
231/// outcome rather than propagating it — the per-file isolation [`plan`]'s
232/// doc promises.
233async 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    // No-op short-circuit: the destination is already the file's sole
278    // parent. Checked before any `permissions.list` call — nothing to
279    // diff, since nothing would change.
280    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, &current_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
328/// Fetches `folder_id`'s permissions, reusing `cache` when another file in
329/// the same batch already fetched it (the destination, or a shared current
330/// parent) — the batch-wide `permissions.list` cache-hit behavior tested
331/// via wiremock call-count assertions.
332async 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/// Executes a [`MovePlan`], moving every file still `WouldMove`.
346///
347/// The rest (`AlreadyInFolder`/`Blocked`/already-`Failed`) pass through
348/// unchanged. A `files.update` failure becomes that file's own
349/// [`MoveResult::Failed`] — the batch continues regardless. Every outcome
350/// — moved, blocked, already-in-folder, or failed — is logged via
351/// [`request_log::record_drive_mutation`] from inside this function, not
352/// the CLI layer: "every move must be logged" needs to hold for every
353/// current and future caller, and a `Blocked` outcome makes no API call at
354/// all, so this is the only place that refusal is ever recorded.
355#[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
381/// Builds and writes the [`DriveMutationOutcome`] for one `move` attempt.
382fn 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    // ── plan: destination validation ────────────────────────────────
469
470    #[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    // ── plan: per-file outcomes ──────────────────────────────────────
510
511    #[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        // No `permissions` mock mounted at all for either id — asserting
534        // the short-circuit via absence would only prove "nothing 404'd";
535        // this instead asserts on a mock that would fail the test if
536        // called more than the expected zero times for the file, while
537        // the destination's own permissions fetch is expected exactly
538        // once (plan() always primes the cache with it up front,
539        // independent of any file's outcome).
540        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    // ── plan: batch behavior ─────────────────────────────────────────
724
725    #[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        // Shared parent and destination must each be fetched exactly once,
760        // despite two files sharing them.
761        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        // wiremock's .expect(1) assertions above are the real check —
787        // this just confirms both outcomes were classified successfully.
788        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        // The batch-level destination-permissions precondition must succeed
811        // so the failure below is isolated to f1's own permissions.list call.
812        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    // ── execute ──────────────────────────────────────────────────────
880
881    #[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    // ── MoveResult::log_status ───────────────────────────────────────
965
966    #[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}