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        decided_by_file_id: None,
400        error,
401        duration,
402        ..Default::default()
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, DriveGrantedScopes};
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: DriveGrantedScopes::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}