From e6ef7a9f20363523c143836d37315132f8c050f3 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:50:22 -0300 Subject: [PATCH 1/5] feat(docs): add suggestion workflow helper --- .changeset/docs-suggest-workflow.md | 7 + README.md | 20 + .../google-workspace-cli/src/helpers/docs.rs | 7 + .../src/helpers/docs/read.rs | 6 +- .../src/helpers/docs/suggest.rs | 419 ++++++++++++++++++ 5 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 .changeset/docs-suggest-workflow.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/suggest.rs diff --git a/.changeset/docs-suggest-workflow.md b/.changeset/docs-suggest-workflow.md new file mode 100644 index 000000000..a9948d1bf --- /dev/null +++ b/.changeset/docs-suggest-workflow.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +suggest` for creating and managing Google Docs suggestions, +including suggested insertions, exact replacements, range deletions, and +accept, reject, or delete actions. \ No newline at end of file diff --git a/README.md b/README.md index 41f1c21eb..1b63f2c34 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ For example, Docs suggestions and comments require a Cloud project enrolled in t Google still enforces API availability, OAuth scopes, document permissions and server-side validation. This flag grants no additional access. +### Docs suggestions + +On `develop`, `gws docs +suggest` provides a guided workflow for Google Docs +suggestions. It can insert text, replace one exact text run, propose a range +deletion, list the structured document with suggestion context, and accept, +reject, or delete an existing suggestion: + +```bash +gws docs +suggest insert --document DOC_ID --text 'Suggested text' +gws docs +suggest replace --document DOC_ID --find 'old text' --text 'new text' +gws docs +suggest delete-text --document DOC_ID --start-index 10 --end-index 20 +gws docs +suggest list --document DOC_ID +gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID +``` + +The helper applies the preview-only `writeMode` request fields internally, so +these commands do not need `--allow-unknown-fields`. Suggestion writes remain +subject to Google Workspace Developer Preview access and document permissions. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ @@ -438,6 +457,7 @@ gws drive --help # shows +upload … | `sheets` | `+append` | Append a row to a spreadsheet | | `sheets` | `+read` | Read values from a spreadsheet | | `docs` | `+write` | Append text to a document | +| `docs` | `+suggest` | Create and manage document suggestions | | `chat` | `+send` | Send a message to a space | | `drive` | `+upload` | Upload a file with automatic metadata | | `calendar` | `+insert` | Create a new event | diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index c42c02feb..ea538921d 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -22,6 +22,7 @@ use std::future::Future; use std::pin::Pin; mod read; +mod suggest; pub struct DocsHelper; @@ -36,6 +37,7 @@ impl Helper for DocsHelper { _doc: &crate::discovery::RestDescription, ) -> Command { cmd = cmd.subcommand(read::command()); + cmd = cmd.subcommand(suggest::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -77,6 +79,10 @@ TIPS: read::handle(doc, matches, sanitize_config).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+suggest") { + suggest::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; @@ -219,4 +225,5 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } + } diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 4e2e8625c..b49eec62f 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -76,7 +76,7 @@ pub(super) async fn handle( /// Token acquisition is lazy so local validation and dry-run never read /// credentials. The executor remains responsible for HTTP and sanitization. -pub(super) async fn run( +pub(crate) async fn run( doc: &RestDescription, matches: &ArgMatches, sanitize: &SanitizeConfig, @@ -94,7 +94,9 @@ pub(super) async fn run( let dry_run = matches.get_flag("dry-run"); let token = if dry_run { None } else { Some(token.await?) }; let format = matches - .get_one::("format") + .try_get_one::("format") + .ok() + .flatten() .map(|f| OutputFormat::from_str(f)) .unwrap_or_default(); let result = executor::execute_method( diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs new file mode 100644 index 000000000..31a83c836 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -0,0 +1,419 @@ +use super::read; +use crate::auth; +use crate::discovery::{RestDescription, RestMethod}; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, BodyValidationPolicy, PaginationConfig}; +use crate::formatter::OutputFormat; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Value}; + +pub(super) fn command() -> Command { + let document = || { + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID") + }; + let mut cmd = Command::new("+suggest").about("[Helper] Create and manage Docs suggestions"); + cmd = cmd.subcommand( + Command::new("insert") + .about("Insert text as a suggestion") + .arg(document()) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("replace") + .about("Replace one exact text run as a suggestion") + .arg(document()) + .arg( + Arg::new("find") + .long("find") + .help("Exact text to replace") + .required(true) + .value_name("TEXT"), + ) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("delete-text") + .about("Propose deleting a document range") + .arg(document()) + .arg(index_arg("start-index")) + .arg(index_arg("end-index")) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("list") + .about("List suggestions with document context") + .arg(document()) + .arg(Arg::new("params").long("params").value_name("JSON")), + ); + for action in ["accept", "reject", "delete"] { + cmd = cmd.subcommand( + Command::new(action) + .about(format!("{} an existing suggestion", capitalize(action))) + .arg(document()) + .arg( + Arg::new("suggestion-id") + .long("suggestion-id") + .required(true) + .value_name("ID"), + ), + ); + } + cmd.after_help( + "EXAMPLES:\n gws docs +suggest insert --document DOC_ID --text 'Suggested text'\n gws docs +suggest replace --document DOC_ID --find 'old' --text 'new'\n gws docs +suggest list --document DOC_ID\n gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID\n\nTIPS:\n Suggestion writes are a Google Workspace Developer Preview feature.\n The helper opts into unknown preview fields internally; raw commands remain strict.\n Use --dry-run to preview insert, delete-text, accept, reject, and delete requests.\n replace reads the document to locate exactly one matching text run before writing.", + ) +} + +fn text_arg() -> Arg { + Arg::new("text") + .long("text") + .help("Text to insert") + .required(true) + .value_name("TEXT") +} + +fn index_arg(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .help("UTF-16 document index") + .required(true) + .value_parser(clap::value_parser!(i32)) +} + +fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let (action, action_matches) = matches + .subcommand() + .ok_or_else(|| GwsError::Validation("docs +suggest requires an action".into()))?; + if action == "list" { + return handle_list(doc, action_matches, sanitize).await; + } + + let (params, body, method) = match action { + "insert" => { + let method = batch_update_method(doc)?; + let body = build_insert_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "replace" => { + let method = batch_update_method(doc)?; + let body = build_replace_body(doc, action_matches, sanitize).await?; + (document_params(action_matches)?, body, method) + } + "delete-text" => { + let method = batch_update_method(doc)?; + let body = build_delete_text_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "accept" | "reject" | "delete" => { + let method = batch_update_method(doc)?; + let body = build_suggestion_action_body(action, action_matches)?; + (document_params(action_matches)?, body, method) + } + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + + execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await +} + +async fn handle_list( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let output = read::run(doc, matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + println!("{output}"); + Ok(()) +} + +async fn execute_suggestion_write( + doc: &RestDescription, + method: &RestMethod, + params: &str, + body: &str, + sanitize: &SanitizeConfig, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let dry_run = matches.get_flag("dry-run"); + let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let token = if dry_run { + None + } else { + Some( + auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?, + ) + }; + executor::execute_method_with_policy( + doc, + method, + Some(params), + Some(body), + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &OutputFormat::default(), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .map(|_| ()) +} + +fn batch_update_method(doc: &RestDescription) -> Result<&RestMethod, GwsError> { + doc.resources + .get("documents") + .and_then(|resource| resource.methods.get("batchUpdate")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.batchUpdate' not found".into())) +} + +fn document_params(matches: &ArgMatches) -> Result { + let document = matches + .get_one::("document") + .ok_or_else(|| GwsError::Validation("Document ID is required".into()))?; + crate::validate::validate_resource_name(document)?; + Ok(json!({"documentId": document}).to_string()) +} + +fn tab_id(matches: &ArgMatches) -> Option<&str> { + matches.get_one::("tab-id").map(String::as_str) +} + +fn insert_location(matches: &ArgMatches) -> Value { + let mut location = json!({"segmentId": ""}); + if let Some(tab_id) = tab_id(matches) { + location["tabId"] = json!(tab_id); + } + location +} + +fn build_insert_body(matches: &ArgMatches) -> Result { + let text = matches + .get_one::("text") + .ok_or_else(|| GwsError::Validation("Text is required".into()))?; + Ok(json!({ + "requests": [{"insertText": {"text": text, "endOfSegmentLocation": insert_location(matches)}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +async fn build_replace_body( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result { + if matches.get_flag("dry-run") { + return Err(GwsError::Validation( + "replace cannot use --dry-run because it must read the document to locate the unique match" + .into(), + )); + } + let document = matches.get_one::("document").unwrap(); + let find = matches.get_one::("find").unwrap(); + let read_matches = read_command_matches(document)?; + let normalized = read::run(doc, &read_matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let text = matches.get_one::("text").unwrap(); + let mut delete_range = json!({"startIndex": start, "endIndex": end}); + let mut insert_location = json!({"index": start}); + if let Some(tab_id) = tab_id { + delete_range["tabId"] = json!(tab_id); + insert_location["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [ + {"deleteContentRange": {"range": delete_range}}, + {"insertText": {"text": text, "location": insert_location}} + ], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn read_command_matches(document: &str) -> Result { + read::command() + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue), + ) + .try_get_matches_from(["+read", "--document", document]) + .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) +} + +fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { + let mut matches = Vec::new(); + find_text_runs(document, needle, &mut matches); + match matches.as_slice() { + [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), + [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), + _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + } +} + +fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { + let Ok(value) = serde_json::from_str::(value) else { + return; + }; + walk_text_runs(&value, needle, None, matches); +} + +fn walk_text_runs( + value: &Value, + needle: &str, + current_tab: Option, + matches: &mut Vec<(Option, i32, i32)>, +) { + match value { + Value::Object(object) => { + let tab = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(current_tab); + if object.get("type").and_then(Value::as_str) == Some("text") { + if let (Some(text), Some(start), Some(run_end)) = ( + object.get("text").and_then(Value::as_str), + object.get("startIndex").and_then(Value::as_i64), + object.get("endIndex").and_then(Value::as_i64), + ) { + if let Some(offset) = text.find(needle) { + let start = start + text[..offset].encode_utf16().count() as i64; + let end = start + needle.encode_utf16().count() as i64; + if end <= run_end { + matches.push((tab.clone(), start as i32, end as i32)); + } + } + } + } + for child in object.values() { + walk_text_runs(child, needle, tab.clone(), matches); + } + } + Value::Array(array) => { + for child in array { + walk_text_runs(child, needle, current_tab.clone(), matches); + } + } + _ => {} + } +} + +fn build_delete_text_body(matches: &ArgMatches) -> Result { + let start = *matches.get_one::("start-index").unwrap(); + let end = *matches.get_one::("end-index").unwrap(); + if start < 0 || end <= start { + return Err(GwsError::Validation( + "end-index must be greater than start-index and both must be non-negative".into(), + )); + } + let mut range = json!({"startIndex": start, "endIndex": end}); + if let Some(tab_id) = tab_id(matches) { + range["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [{"deleteContentRange": {"range": range}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result { + let suggestion_id = matches.get_one::("suggestion-id").unwrap(); + let request = match action { + "accept" => json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), + "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), + "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + Ok(json!({"requests": [request]}).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_body_uses_suggest_mode() { + let matches = command() + .try_get_matches_from(["+suggest", "insert", "--document", "doc", "--text", "hello"]) + .unwrap(); + let body = build_insert_body(matches.subcommand_matches("insert").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["writeControl"]["writeMode"], "SUGGEST"); + assert_eq!(body["requests"][0]["insertText"]["text"], "hello"); + } + + #[test] + fn delete_text_rejects_invalid_range() { + let matches = command() + .try_get_matches_from([ + "+suggest", "delete-text", "--document", "doc", "--start-index", "4", + "--end-index", "4", + ]) + .unwrap(); + let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("end-index")); + } + + #[test] + fn suggestion_action_uses_requested_id() { + let matches = command() + .try_get_matches_from([ + "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + ]) + .unwrap(); + let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert!(body.get("writeControl").is_none()); + } + + #[test] + fn finds_unique_match_using_utf16_indices() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "😀B").unwrap(), + (Some("tab-1".into()), 6, 9) + ); + } +} From 4716d5df008cb2b416bd872841a98c79affedff4 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:53:57 -0300 Subject: [PATCH 2/5] style: format docs suggestion helper --- .../src/helpers/docs/suggest.rs | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 31a83c836..452c09faf 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -127,7 +127,11 @@ pub(super) async fn handle( let body = build_suggestion_action_body(action, action_matches)?; (document_params(action_matches)?, body, method) } - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await @@ -279,13 +283,20 @@ fn read_command_matches(document: &str) -> Result { .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) } -fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { +fn find_unique_text_run( + document: &str, + needle: &str, +) -> Result<(Option, i32, i32), GwsError> { let mut matches = Vec::new(); find_text_runs(document, needle, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), - [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), - _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + [] => Err(GwsError::Validation( + "Text to replace was not found in one text run".into(), + )), + _ => Err(GwsError::Validation( + "Text to replace matched more than once".into(), + )), } } @@ -362,7 +373,11 @@ fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; Ok(json!({"requests": [request]}).to_string()) } @@ -386,12 +401,18 @@ mod tests { fn delete_text_rejects_invalid_range() { let matches = command() .try_get_matches_from([ - "+suggest", "delete-text", "--document", "doc", "--start-index", "4", - "--end-index", "4", + "+suggest", + "delete-text", + "--document", + "doc", + "--start-index", + "4", + "--end-index", + "4", ]) .unwrap(); - let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) - .unwrap_err(); + let error = + build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()).unwrap_err(); assert!(error.to_string().contains("end-index")); } @@ -399,12 +420,22 @@ mod tests { fn suggestion_action_uses_requested_id() { let matches = command() .try_get_matches_from([ - "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + "+suggest", + "accept", + "--document", + "doc", + "--suggestion-id", + "s1", ]) .unwrap(); - let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body = + build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()) + .unwrap(); let body: Value = serde_json::from_str(&body).unwrap(); - assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert_eq!( + body["requests"][0]["acceptSuggestion"]["suggestionId"], + "s1" + ); assert!(body.get("writeControl").is_none()); } From 57542582650c0633297d2ac10832d393466fac2f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:54:19 -0300 Subject: [PATCH 3/5] style: apply rustfmt to docs helper tests --- crates/google-workspace-cli/src/helpers/docs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index ea538921d..ac9d712e6 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -225,5 +225,4 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } - } From 9ea63dbdb8d70da69fb2c46b20c22f6745bc70b9 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:53:14 -0300 Subject: [PATCH 4/5] fix(docs): harden suggested replacements --- .../src/helpers/docs/suggest.rs | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 452c09faf..da8fe046b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -247,6 +247,11 @@ async fn build_replace_body( } let document = matches.get_one::("document").unwrap(); let find = matches.get_one::("find").unwrap(); + if find.is_empty() { + return Err(GwsError::Validation( + "find must not be empty when replacing text".into(), + )); + } let read_matches = read_command_matches(document)?; let normalized = read::run(doc, &read_matches, sanitize, async { auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) @@ -254,7 +259,19 @@ async fn build_replace_body( .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) }) .await?; - let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let normalized_value: Value = serde_json::from_str(&normalized) + .map_err(|e| GwsError::Validation(format!("Invalid normalized Docs response: {e}")))?; + let revision_id = normalized_value + .get("revisionId") + .and_then(Value::as_str) + .filter(|revision| !revision.is_empty()) + .ok_or_else(|| { + GwsError::Validation( + "Docs response did not include a revisionId; refusing an unprotected replacement" + .into(), + ) + })?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find, tab_id(matches))?; let text = matches.get_one::("text").unwrap(); let mut delete_range = json!({"startIndex": start, "endIndex": end}); let mut insert_location = json!({"index": start}); @@ -267,7 +284,7 @@ async fn build_replace_body( {"deleteContentRange": {"range": delete_range}}, {"insertText": {"text": text, "location": insert_location}} ], - "writeControl": {"writeMode": "SUGGEST"} + "writeControl": {"writeMode": "SUGGEST", "requiredRevisionId": revision_id} }) .to_string()) } @@ -286,9 +303,15 @@ fn read_command_matches(document: &str) -> Result { fn find_unique_text_run( document: &str, needle: &str, + requested_tab: Option<&str>, ) -> Result<(Option, i32, i32), GwsError> { + if needle.is_empty() { + return Err(GwsError::Validation( + "Text to replace must not be empty".into(), + )); + } let mut matches = Vec::new(); - find_text_runs(document, needle, &mut matches); + find_text_runs(document, needle, requested_tab, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), [] => Err(GwsError::Validation( @@ -300,11 +323,27 @@ fn find_unique_text_run( } } -fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { +fn find_text_runs( + value: &str, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { let Ok(value) = serde_json::from_str::(value) else { return; }; - walk_text_runs(&value, needle, None, matches); + let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { + return; + }; + for tab in tabs { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { + continue; + } + if let Some(blocks) = tab.get("blocks") { + walk_text_runs(blocks, needle, tab_id, matches); + } + } } fn walk_text_runs( @@ -326,7 +365,7 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - if let Some(offset) = text.find(needle) { + for offset in text.match_indices(needle).map(|(offset, _)| offset) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -443,8 +482,29 @@ mod tests { fn finds_unique_match_using_utf16_indices() { let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; assert_eq!( - find_unique_text_run(document, "😀B").unwrap(), + find_unique_text_run(document, "😀B", None).unwrap(), (Some("tab-1".into()), 6, 9) ); } + + #[test] + fn rejects_empty_replacement_text() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"hello","startIndex":1,"endIndex":6}]}]}]}"#; + assert!(find_unique_text_run(document, "", None).is_err()); + } + + #[test] + fn rejects_duplicate_matches_within_one_text_run() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo foo","startIndex":1,"endIndex":8}]}]}]}"#; + assert!(find_unique_text_run(document, "foo", None).is_err()); + } + + #[test] + fn filters_replacement_matches_by_tab_id() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]},{"tabId":"tab-2","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "foo", Some("tab-2")).unwrap(), + (Some("tab-2".into()), 1, 4) + ); + } } From 19a9155f9d3490dced205b374bf8350a93404b71 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:03:05 -0300 Subject: [PATCH 5/5] fix(docs): handle nested suggestion replacements --- .../src/helpers/docs/suggest.rs | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index da8fe046b..a638c096e 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -161,7 +161,7 @@ async fn execute_suggestion_write( matches: &ArgMatches, ) -> Result<(), GwsError> { let dry_run = matches.get_flag("dry-run"); - let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let token = if dry_run { None } else { @@ -332,16 +332,28 @@ fn find_text_runs( let Ok(value) = serde_json::from_str::(value) else { return; }; - let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { - return; - }; - for tab in tabs { - let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); - if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { - continue; + if let Some(tabs) = value.get("tabs").and_then(Value::as_array) { + for tab in tabs { + walk_tab_text_runs(tab, needle, requested_tab, matches); } + } +} + +fn walk_tab_text_runs( + tab: &Value, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_none_or(|requested| tab_id.as_deref() == Some(requested)) { if let Some(blocks) = tab.get("blocks") { - walk_text_runs(blocks, needle, tab_id, matches); + walk_text_runs(blocks, needle, tab_id.clone(), matches); + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + walk_tab_text_runs(child, needle, requested_tab, matches); } } } @@ -365,7 +377,9 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - for offset in text.match_indices(needle).map(|(offset, _)| offset) { + for offset in text.char_indices().filter_map(|(offset, _)| { + text[offset..].starts_with(needle).then_some(offset) + }) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -507,4 +521,19 @@ mod tests { (Some("tab-2".into()), 1, 4) ); } + + #[test] + fn rejects_overlapping_matches() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"aaa","startIndex":1,"endIndex":4}]}]}]}"#; + assert!(find_unique_text_run(document, "aa", None).is_err()); + } + + #[test] + fn finds_text_in_child_tabs() { + let document = r#"{"tabs":[{"tabId":"root","blocks":[],"childTabs":[{"tabId":"child","blocks":[{"elements":[{"type":"text","text":"child text","startIndex":1,"endIndex":11}]}],"childTabs":[]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "child", Some("child")).unwrap(), + (Some("child".into()), 1, 6) + ); + } }