1use 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#[derive(Debug, Clone, Serialize)]
22pub struct RenameOutcome {
23 pub file_id: String,
25 pub old_name: String,
27 pub new_name: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub requested_name: Option<String>,
34}
35
36impl RenameOutcome {
37 #[must_use]
40 pub fn is_unchanged(&self) -> bool {
41 self.old_name == self.new_name
42 }
43
44 #[must_use]
47 pub fn is_ignored(&self) -> bool {
48 self.is_unchanged() && self.requested_name.is_some()
49 }
50}
51
52pub 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
76pub 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
110fn 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 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 file_name: new_name.to_string(),
132 status,
133 added_principals: Vec::new(),
136 removed_principals: Vec::new(),
137 crosses_drive_boundary: false,
138 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 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}