Skip to main content

omni_dev/drive/
rename.rs

1//! Drive file rename.
2//!
3//! Renaming only ever touches a file's `name` field and never changes
4//! `parents`, so — unlike `move` — it can never change who can see the
5//! file. There is nothing to gate: rename always proceeds (subject to the
6//! usual API/auth failures), but it still goes through the same audit-log
7//! path `move` does, since "every move/rename must be logged" (#1557) is an
8//! invariant that applies to both operations equally.
9
10use std::time::{Duration, Instant};
11
12use anyhow::{bail, Result};
13use serde::Serialize;
14
15use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
16use crate::drive::client::DriveClient;
17use crate::drive::files_api::FilesApi;
18use crate::request_log::{self, DriveMutationOutcome};
19
20/// The result of a successful rename.
21#[derive(Debug, Clone, Serialize)]
22pub struct RenameOutcome {
23    /// The Drive file id acted on.
24    pub file_id: String,
25    /// The file's name before this rename.
26    pub old_name: String,
27    /// The file's name after this rename, as `files.update` returned it —
28    /// not the requested string, since Drive can normalise or ignore one.
29    pub new_name: String,
30    /// The name that was asked for, set only when Drive stored a different
31    /// one (so the rename was normalised or ignored). Omitted otherwise.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub requested_name: Option<String>,
34}
35
36impl RenameOutcome {
37    /// Whether the file already carried the resulting name, so the call
38    /// changed nothing.
39    #[must_use]
40    pub fn is_unchanged(&self) -> bool {
41        self.old_name == self.new_name
42    }
43
44    /// Whether Drive answered 200 but kept the old name although a different
45    /// one was asked for — the rename did not happen.
46    #[must_use]
47    pub fn is_ignored(&self) -> bool {
48        self.is_unchanged() && self.requested_name.is_some()
49    }
50}
51
52/// Rejects a `new_name` that is empty, whitespace-only, or made only of
53/// invisible characters (control characters, zero-width spaces, BOM).
54///
55/// Drive treats an empty `name` as "no change" and answers 200, so without
56/// this check a no-op is reported as a successful rename (#1918). Only
57/// blankness is rejected; the name that is sent is never trimmed.
58pub fn validate_new_name(new_name: &str) -> Result<()> {
59    let invisible = |c: char| {
60        c.is_whitespace()
61            || c.is_control()
62            || matches!(c, '\u{200B}'..='\u{200D}' | '\u{2060}' | '\u{FEFF}')
63    };
64    if new_name.chars().all(invisible) {
65        bail!("The new name must not be empty or whitespace-only");
66    }
67    Ok(())
68}
69
70impl JsonlSerialize for RenameOutcome {
71    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
72        write_scalar_jsonl(self, out)
73    }
74}
75
76/// Renames `file_id` to `new_name`.
77///
78/// Fetches the current name first (`files.get`) — both an existence check
79/// and what lets the log show old→new — then calls `files.update`. Always
80/// records a [`DriveMutationOutcome`] via
81/// [`request_log::record_drive_mutation`], on both success and failure:
82/// logging happens here, inside the engine, rather than at the CLI call
83/// site, so the "every move/rename must be logged" invariant holds for
84/// every current and future caller (CLI today, a possible MCP tool later).
85pub async fn rename(client: &DriveClient, file_id: &str, new_name: &str) -> Result<RenameOutcome> {
86    let started = Instant::now();
87    let result = rename_inner(client, file_id, new_name).await;
88    record_attempt(file_id, new_name, &result, started.elapsed());
89    result
90}
91
92async fn rename_inner(
93    client: &DriveClient,
94    file_id: &str,
95    new_name: &str,
96) -> Result<RenameOutcome> {
97    validate_new_name(new_name)?;
98    let files = FilesApi::new(client);
99    let existing = files.get_metadata(file_id).await?;
100    let updated = files.rename(file_id, new_name).await?;
101    let requested_name = (updated.name != new_name).then(|| new_name.to_string());
102    Ok(RenameOutcome {
103        file_id: file_id.to_string(),
104        old_name: existing.name,
105        new_name: updated.name,
106        requested_name,
107    })
108}
109
110/// Builds and writes the [`DriveMutationOutcome`] for one `rename` attempt.
111/// Split out from [`rename`] purely for readability — not otherwise reused.
112fn record_attempt(
113    file_id: &str,
114    new_name: &str,
115    result: &Result<RenameOutcome>,
116    duration: Duration,
117) {
118    let (status, error) = match result {
119        Ok(outcome) if outcome.is_ignored() => ("ignored".to_string(), None),
120        Ok(outcome) if outcome.is_unchanged() => ("unchanged".to_string(), None),
121        Ok(_) => ("renamed".to_string(), None),
122        Err(err) => ("failed".to_string(), Some(err.to_string())),
123    };
124    // On success, the name Drive actually stored.
125    let new_name = result.as_ref().map_or(new_name, |o| o.new_name.as_str());
126    request_log::record_drive_mutation(DriveMutationOutcome {
127        operation: "rename",
128        file_id: file_id.to_string(),
129        // The target name (the stored one once known) — the best-known name
130        // whether or not `files.get` resolved the current one first.
131        file_name: new_name.to_string(),
132        status,
133        // Rename never changes `parents`, so it never has a visibility
134        // diff to report.
135        added_principals: Vec::new(),
136        removed_principals: Vec::new(),
137        crosses_drive_boundary: false,
138        // Rename is never gated by the folder write-permission gate.
139        resolved_folder_id: None,
140        decided_by_folder_id: None,
141        decided_by_depth: None,
142        decided_by_file_id: None,
143        error,
144        duration,
145        ..Default::default()
146    });
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152    use super::*;
153    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
154    use crate::utils::secret::Secret;
155
156    fn test_credentials() -> DriveCredentials {
157        DriveCredentials {
158            client_id: "client-1".to_string(),
159            client_secret: Secret::new("secret-1"),
160            refresh_token: Secret::new("refresh-1"),
161            scope: DriveGrantedScopes::METADATA,
162        }
163    }
164
165    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
166        wiremock::Mock::given(wiremock::matchers::method("POST"))
167            .and(wiremock::matchers::path("/token"))
168            .respond_with(
169                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
170                    "access_token": "test-token",
171                    "expires_in": 3600,
172                })),
173            )
174            .mount(server)
175            .await;
176
177        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
178        crate::drive::client::test_support::replace_session(
179            &mut client,
180            &test_credentials(),
181            &format!("{}/token", server.uri()),
182        );
183        client
184    }
185
186    #[tokio::test]
187    async fn rename_fetches_old_name_then_renames() {
188        let server = wiremock::MockServer::start().await;
189        let client = client_with_bootstrapped_token(&server).await;
190        wiremock::Mock::given(wiremock::matchers::method("GET"))
191            .and(wiremock::matchers::path("/drive/v3/files/f1"))
192            .respond_with(
193                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
194                    "id": "f1", "name": "Old Name",
195                })),
196            )
197            .mount(&server)
198            .await;
199        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
200            .and(wiremock::matchers::path("/drive/v3/files/f1"))
201            .respond_with(
202                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
203                    "id": "f1", "name": "New Name",
204                })),
205            )
206            .mount(&server)
207            .await;
208
209        let outcome = rename(&client, "f1", "New Name").await.unwrap();
210        assert_eq!(outcome.file_id, "f1");
211        assert_eq!(outcome.old_name, "Old Name");
212        assert_eq!(outcome.new_name, "New Name");
213    }
214
215    fn mount_get(name: &str) -> wiremock::Mock {
216        wiremock::Mock::given(wiremock::matchers::method("GET"))
217            .and(wiremock::matchers::path("/drive/v3/files/f1"))
218            .respond_with(
219                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
220                    "id": "f1", "name": name,
221                })),
222            )
223    }
224
225    fn mount_patch(returned_name: &str) -> wiremock::Mock {
226        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
227            .and(wiremock::matchers::path("/drive/v3/files/f1"))
228            .respond_with(
229                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
230                    "id": "f1", "name": returned_name,
231                })),
232            )
233    }
234
235    #[test]
236    fn validate_new_name_rejects_blank_names() {
237        for blank in [
238            "",
239            " ",
240            "   ",
241            "\t",
242            "\n",
243            " \t\n ",
244            "\u{a0}",
245            "\u{3000}",
246            "\u{200b}",
247            "\u{feff}",
248            " \u{200b}\u{2060} ",
249        ] {
250            let err = validate_new_name(blank).unwrap_err();
251            assert!(
252                err.to_string().contains("empty or whitespace-only"),
253                "{blank:?}: {err}"
254            );
255        }
256    }
257
258    #[test]
259    fn validate_new_name_accepts_names_with_content() {
260        for ok in ["a", " a", "a ", "  Q3 Report  ", "-", "\u{1F4C4}"] {
261            validate_new_name(ok).unwrap();
262        }
263    }
264
265    #[tokio::test]
266    async fn rename_rejects_a_blank_name_before_any_api_call() {
267        let server = wiremock::MockServer::start().await;
268        let client = client_with_bootstrapped_token(&server).await;
269        // No GET/PATCH mocks: an API call would surface as a different error.
270        for blank in ["", "   "] {
271            let err = rename(&client, "f1", blank).await.unwrap_err();
272            assert!(
273                err.to_string().contains("empty or whitespace-only"),
274                "{err}"
275            );
276        }
277        let requests = server.received_requests().await.unwrap();
278        assert!(
279            requests
280                .iter()
281                .all(|r| r.url.path() == "/token" || !r.url.path().contains("/files/")),
282            "a blank name must not reach the files API: {requests:?}"
283        );
284    }
285
286    #[tokio::test]
287    async fn rename_reports_the_name_drive_returned_not_the_requested_one() {
288        let server = wiremock::MockServer::start().await;
289        let client = client_with_bootstrapped_token(&server).await;
290        mount_get("Old Name").mount(&server).await;
291        mount_patch("Normalised Name").mount(&server).await;
292
293        let outcome = rename(&client, "f1", "  Requested  ").await.unwrap();
294        assert_eq!(outcome.new_name, "Normalised Name");
295        assert_eq!(outcome.requested_name.as_deref(), Some("  Requested  "));
296        assert!(!outcome.is_unchanged());
297    }
298
299    #[tokio::test]
300    async fn rename_flags_a_name_drive_ignored() {
301        let server = wiremock::MockServer::start().await;
302        let client = client_with_bootstrapped_token(&server).await;
303        mount_get("Old Name").mount(&server).await;
304        mount_patch("Old Name").mount(&server).await;
305
306        let outcome = rename(&client, "f1", "New Name").await.unwrap();
307        assert_eq!(outcome.new_name, "Old Name");
308        assert_eq!(outcome.requested_name.as_deref(), Some("New Name"));
309        assert!(outcome.is_unchanged());
310        assert!(outcome.is_ignored());
311    }
312
313    #[tokio::test]
314    async fn rename_to_the_current_name_is_unchanged_without_a_requested_name() {
315        let server = wiremock::MockServer::start().await;
316        let client = client_with_bootstrapped_token(&server).await;
317        mount_get("Same").mount(&server).await;
318        mount_patch("Same").mount(&server).await;
319
320        let outcome = rename(&client, "f1", "Same").await.unwrap();
321        assert!(outcome.is_unchanged());
322        assert!(!outcome.is_ignored());
323        assert_eq!(outcome.requested_name, None);
324        let json = serde_json::to_value(&outcome).unwrap();
325        assert!(json.get("requested_name").is_none(), "{json}");
326    }
327
328    #[tokio::test]
329    async fn rename_propagates_a_missing_file_error() {
330        let server = wiremock::MockServer::start().await;
331        let client = client_with_bootstrapped_token(&server).await;
332        wiremock::Mock::given(wiremock::matchers::method("GET"))
333            .and(wiremock::matchers::path("/drive/v3/files/missing"))
334            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
335            .mount(&server)
336            .await;
337
338        let err = rename(&client, "missing", "New Name").await.unwrap_err();
339        assert!(err.to_string().contains("404"));
340    }
341
342    #[tokio::test]
343    async fn rename_propagates_a_files_update_error_after_a_successful_get() {
344        let server = wiremock::MockServer::start().await;
345        let client = client_with_bootstrapped_token(&server).await;
346        wiremock::Mock::given(wiremock::matchers::method("GET"))
347            .and(wiremock::matchers::path("/drive/v3/files/f1"))
348            .respond_with(
349                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
350                    "id": "f1", "name": "Old Name",
351                })),
352            )
353            .mount(&server)
354            .await;
355        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
356            .and(wiremock::matchers::path("/drive/v3/files/f1"))
357            .respond_with(
358                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
359                    "error": {
360                        "message": "Insufficient Permission",
361                        "errors": [{"reason": "insufficientPermissions"}],
362                    }
363                })),
364            )
365            .mount(&server)
366            .await;
367
368        let err = rename(&client, "f1", "New Name").await.unwrap_err();
369        assert!(
370            err.to_string().contains("drive auth login --write"),
371            "{err}"
372        );
373    }
374}