1use 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#[derive(Debug, Clone, Serialize)]
22pub struct RenameOutcome {
23 pub file_id: String,
25 pub old_name: String,
27 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
37pub 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
68fn 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 file_name: new_name.to_string(),
86 status,
87 added_principals: Vec::new(),
90 removed_principals: Vec::new(),
91 crosses_drive_boundary: false,
92 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}