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::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.
28    pub new_name: String,
29}
30
31impl JsonlSerialize for RenameOutcome {
32    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
33        write_scalar_jsonl(self, out)
34    }
35}
36
37/// Renames `file_id` to `new_name`.
38///
39/// Fetches the current name first (`files.get`) — both an existence check
40/// and what lets the log show old→new — then calls `files.update`. Always
41/// records a [`DriveMutationOutcome`] via
42/// [`request_log::record_drive_mutation`], on both success and failure:
43/// logging happens here, inside the engine, rather than at the CLI call
44/// site, so the "every move/rename must be logged" invariant holds for
45/// every current and future caller (CLI today, a possible MCP tool later).
46pub async fn rename(client: &DriveClient, file_id: &str, new_name: &str) -> Result<RenameOutcome> {
47    let started = Instant::now();
48    let result = rename_inner(client, file_id, new_name).await;
49    record_attempt(file_id, new_name, &result, started.elapsed());
50    result
51}
52
53async fn rename_inner(
54    client: &DriveClient,
55    file_id: &str,
56    new_name: &str,
57) -> Result<RenameOutcome> {
58    let files = FilesApi::new(client);
59    let existing = files.get_metadata(file_id).await?;
60    files.rename(file_id, new_name).await?;
61    Ok(RenameOutcome {
62        file_id: file_id.to_string(),
63        old_name: existing.name,
64        new_name: new_name.to_string(),
65    })
66}
67
68/// Builds and writes the [`DriveMutationOutcome`] for one `rename` attempt.
69/// Split out from [`rename`] purely for readability — not otherwise reused.
70fn record_attempt(
71    file_id: &str,
72    new_name: &str,
73    result: &Result<RenameOutcome>,
74    duration: Duration,
75) {
76    let (status, error) = match result {
77        Ok(_) => ("renamed".to_string(), None),
78        Err(err) => ("failed".to_string(), Some(err.to_string())),
79    };
80    request_log::record_drive_mutation(DriveMutationOutcome {
81        operation: "rename",
82        file_id: file_id.to_string(),
83        // The target name — the best-known name whether or not `files.get`
84        // resolved the current one first.
85        file_name: new_name.to_string(),
86        status,
87        // Rename never changes `parents`, so it never has a visibility
88        // diff to report.
89        added_principals: Vec::new(),
90        removed_principals: Vec::new(),
91        crosses_drive_boundary: false,
92        // Rename is never gated by the folder write-permission gate.
93        resolved_folder_id: None,
94        decided_by_folder_id: None,
95        decided_by_depth: None,
96        decided_by_file_id: None,
97        error,
98        duration,
99        ..Default::default()
100    });
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used, clippy::expect_used)]
105mod tests {
106    use super::*;
107    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
108    use crate::utils::secret::Secret;
109
110    fn test_credentials() -> DriveCredentials {
111        DriveCredentials {
112            client_id: "client-1".to_string(),
113            client_secret: Secret::new("secret-1"),
114            refresh_token: Secret::new("refresh-1"),
115            scope: DriveGrantedScopes::METADATA,
116        }
117    }
118
119    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
120        wiremock::Mock::given(wiremock::matchers::method("POST"))
121            .and(wiremock::matchers::path("/token"))
122            .respond_with(
123                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
124                    "access_token": "test-token",
125                    "expires_in": 3600,
126                })),
127            )
128            .mount(server)
129            .await;
130
131        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
132        crate::drive::client::test_support::replace_session(
133            &mut client,
134            &test_credentials(),
135            &format!("{}/token", server.uri()),
136        );
137        client
138    }
139
140    #[tokio::test]
141    async fn rename_fetches_old_name_then_renames() {
142        let server = wiremock::MockServer::start().await;
143        let client = client_with_bootstrapped_token(&server).await;
144        wiremock::Mock::given(wiremock::matchers::method("GET"))
145            .and(wiremock::matchers::path("/drive/v3/files/f1"))
146            .respond_with(
147                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
148                    "id": "f1", "name": "Old Name",
149                })),
150            )
151            .mount(&server)
152            .await;
153        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
154            .and(wiremock::matchers::path("/drive/v3/files/f1"))
155            .respond_with(
156                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
157                    "id": "f1", "name": "New Name",
158                })),
159            )
160            .mount(&server)
161            .await;
162
163        let outcome = rename(&client, "f1", "New Name").await.unwrap();
164        assert_eq!(outcome.file_id, "f1");
165        assert_eq!(outcome.old_name, "Old Name");
166        assert_eq!(outcome.new_name, "New Name");
167    }
168
169    #[tokio::test]
170    async fn rename_propagates_a_missing_file_error() {
171        let server = wiremock::MockServer::start().await;
172        let client = client_with_bootstrapped_token(&server).await;
173        wiremock::Mock::given(wiremock::matchers::method("GET"))
174            .and(wiremock::matchers::path("/drive/v3/files/missing"))
175            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
176            .mount(&server)
177            .await;
178
179        let err = rename(&client, "missing", "New Name").await.unwrap_err();
180        assert!(err.to_string().contains("404"));
181    }
182
183    #[tokio::test]
184    async fn rename_propagates_a_files_update_error_after_a_successful_get() {
185        let server = wiremock::MockServer::start().await;
186        let client = client_with_bootstrapped_token(&server).await;
187        wiremock::Mock::given(wiremock::matchers::method("GET"))
188            .and(wiremock::matchers::path("/drive/v3/files/f1"))
189            .respond_with(
190                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
191                    "id": "f1", "name": "Old Name",
192                })),
193            )
194            .mount(&server)
195            .await;
196        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
197            .and(wiremock::matchers::path("/drive/v3/files/f1"))
198            .respond_with(
199                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
200                    "error": {
201                        "message": "Insufficient Permission",
202                        "errors": [{"reason": "insufficientPermissions"}],
203                    }
204                })),
205            )
206            .mount(&server)
207            .await;
208
209        let err = rename(&client, "f1", "New Name").await.unwrap_err();
210        assert!(
211            err.to_string().contains("drive auth login --write"),
212            "{err}"
213        );
214    }
215}