use std::io::Read;
use anyhow::{Context, Result};
use clap::Parser;
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::cli::drive::helpers;
use crate::drive::client::DriveClient;
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::write::{describe, write, WriteOptions, WritePayload};
use crate::drive::write_gate::FolderPermissionRule;
#[derive(Parser)]
pub struct ReplaceCommand {
pub document_id: String,
#[arg(long)]
pub search: String,
#[arg(long)]
pub replace: String,
#[arg(long)]
pub ignore_case: bool,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Parser)]
pub struct AppendCommand {
pub document_id: String,
#[arg(
long,
conflicts_with = "text_file",
required_unless_present = "text_file"
)]
pub text: Option<String>,
#[arg(long, value_name = "PATH")]
pub text_file: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ReplaceCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let docs = DocsClient::from_drive_client(client)?;
let opts = WriteOptions {
document_id: self.document_id,
payload: WritePayload::Replace {
search: self.search,
replace: self.replace,
match_case: !self.ignore_case,
},
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
let rules = helpers::active_account_rules()?;
run_write(client, &docs, &opts, &rules, &self.output).await
}
}
impl AppendCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let docs = DocsClient::from_drive_client(client)?;
let text = match (self.text, self.text_file.as_deref()) {
(Some(text), _) => text,
(None, Some(source)) => read_text(source)?,
(None, None) => anyhow::bail!("one of --text or --text-file is required"),
};
let opts = WriteOptions {
document_id: self.document_id,
payload: WritePayload::Append { text },
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
let rules = helpers::active_account_rules()?;
run_write(client, &docs, &opts, &rules, &self.output).await
}
}
pub(crate) fn read_text(source: &str) -> Result<String> {
let cap = crate::drive::files_api::MAX_UPLOAD_BYTES;
if source == "-" {
let mut buf = Vec::new();
std::io::stdin()
.take(cap + 1)
.read_to_end(&mut buf)
.context("Failed to read --text-file from stdin")?;
anyhow::ensure!(
buf.len() as u64 <= cap,
"--text-file from stdin is over the {cap} byte cap"
);
return String::from_utf8(buf).context("--text-file from stdin is not valid UTF-8");
}
let metadata = std::fs::metadata(source)
.with_context(|| format!("Failed to stat --text-file {source}"))?;
anyhow::ensure!(
metadata.len() <= cap,
"--text-file {source} is {} bytes, over the {cap} byte cap",
metadata.len()
);
std::fs::read_to_string(source).with_context(|| format!("Failed to read --text-file {source}"))
}
async fn run_write(
drive: &DriveClient,
docs: &DocsClient,
opts: &WriteOptions,
rules: &[FolderPermissionRule],
output: &OutputFormat,
) -> Result<()> {
let outcome = write(drive, docs, opts, rules).await;
if output_as(&outcome, output)? {
return Ok(());
}
println!(
"{}",
sanitize_for_terminal(&describe(&outcome, opts.payload.verb()))
);
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn read_text_reads_a_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("note.txt");
std::fs::write(&path, "hello world").unwrap();
assert_eq!(read_text(path.to_str().unwrap()).unwrap(), "hello world");
}
#[test]
fn read_text_refuses_a_file_over_the_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.txt");
let oversized = vec![b'a'; (crate::drive::files_api::MAX_UPLOAD_BYTES + 1) as usize];
std::fs::write(&path, oversized).unwrap();
let err = read_text(path.to_str().unwrap()).unwrap_err().to_string();
assert!(err.contains("over the"), "{err}");
}
#[test]
fn read_text_reports_a_missing_file() {
let err = read_text("/nonexistent/definitely-not-here.txt")
.unwrap_err()
.to_string();
assert!(err.contains("Failed to stat"), "{err}");
}
#[test]
fn read_text_refuses_non_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bin.txt");
std::fs::write(&path, [0xff, 0xfe, 0x00]).unwrap();
assert!(read_text(path.to_str().unwrap()).is_err());
}
#[test]
fn replace_defaults_to_case_sensitive_and_ignore_case_inverts_it() {
use clap::Parser as _;
let cmd =
ReplaceCommand::try_parse_from(["replace", "d1", "--search", "a", "--replace", "b"])
.unwrap();
assert!(!cmd.ignore_case, "case-sensitive by default");
let cmd = ReplaceCommand::try_parse_from([
"replace",
"d1",
"--search",
"a",
"--replace",
"b",
"--ignore-case",
])
.unwrap();
assert!(cmd.ignore_case);
}
#[test]
fn append_requires_exactly_one_of_text_or_text_file() {
use clap::Parser as _;
assert!(AppendCommand::try_parse_from(["append", "d1"]).is_err());
assert!(AppendCommand::try_parse_from([
"append",
"d1",
"--text",
"x",
"--text-file",
"f.txt"
])
.is_err());
assert!(AppendCommand::try_parse_from(["append", "d1", "--text", "x"]).is_ok());
assert!(AppendCommand::try_parse_from(["append", "d1", "--text-file", "f.txt"]).is_ok());
}
}