This is an automated email from the ASF dual-hosted git repository.
tison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git
The following commit(s) were added to refs/heads/main by this push:
new 1df65c566 feat(bin/oli): support oli edit (#6229)
1df65c566 is described below
commit 1df65c566c4fc242945ae730236a68131b73fadf
Author: Asuka Minato <[email protected]>
AuthorDate: Mon Jun 16 18:55:56 2025 +0900
feat(bin/oli): support oli edit (#6229)
---
bin/oli/Cargo.toml | 2 +-
bin/oli/src/commands/edit.rs | 142 +++++++++++++++++++
bin/oli/src/commands/mod.rs | 3 +
bin/oli/tests/integration/edit.rs | 280 ++++++++++++++++++++++++++++++++++++++
bin/oli/tests/integration/main.rs | 1 +
5 files changed, 427 insertions(+), 1 deletion(-)
diff --git a/bin/oli/Cargo.toml b/bin/oli/Cargo.toml
index 2bac85a2e..9a6492b0d 100644
--- a/bin/oli/Cargo.toml
+++ b/bin/oli/Cargo.toml
@@ -58,6 +58,7 @@ opendal = { version = "0.53.0", path = "../../core", features
= [
parse-size = { version = "1.1" }
pollster = { version = "0.4" }
serde = { version = "1.0", features = ["derive"] }
+tempfile = { version = "3.14" }
tokio = { version = "1.42", features = ["full"] }
toml = { version = "0.8" }
url = { version = "2.5" }
@@ -69,5 +70,4 @@ comfy-table = "7"
insta = { version = "1.43", features = ["filters"] }
insta-cmd = { version = "0.6" }
regex = "1"
-tempfile = { version = "3.14" }
walkdir = "2.5.0"
diff --git a/bin/oli/src/commands/edit.rs b/bin/oli/src/commands/edit.rs
new file mode 100644
index 000000000..a6110a5c5
--- /dev/null
+++ b/bin/oli/src/commands/edit.rs
@@ -0,0 +1,142 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use anyhow::{Context, Result};
+use opendal::ErrorKind;
+use std::env;
+use std::fs;
+use std::io::Write;
+use std::process::Command;
+use tempfile::NamedTempFile;
+
+use crate::config::Config;
+use crate::make_tokio_runtime;
+use crate::params::config::ConfigParams;
+
+#[derive(Debug, clap::Parser)]
+#[command(
+ name = "edit",
+ about = "Edit a file from remote storage using local editor",
+ disable_version_flag = true
+)]
+pub struct EditCmd {
+ #[command(flatten)]
+ pub config_params: ConfigParams,
+ /// In the form of `<profile>:/<path>`.
+ #[arg()]
+ pub target: String,
+}
+
+impl EditCmd {
+ pub fn run(self) -> Result<()> {
+ make_tokio_runtime(1).block_on(self.do_run())
+ }
+
+ async fn do_run(self) -> Result<()> {
+ let cfg = Config::load(&self.config_params.config)?;
+ let (op, path) = cfg.parse_location(&self.target)?;
+
+ // Create a temporary file
+ let temp_file = NamedTempFile::new().context("Failed to create
temporary file")?;
+
+ let temp_path = temp_file.path().to_path_buf();
+
+ // Try to read the existing file content
+ let original_content_opendal_buffer = match op.read(&path).await {
+ Ok(content) => {
+ // Write existing content to temp file
+ let mut tf_writer = temp_file.as_file();
+ tf_writer
+ .write_all(content.to_vec().as_slice())
+ .context("Failed to write initial content to temporary
file")?;
+ tf_writer
+ .flush()
+ .context("Failed to flush temporary file after initial
write")?;
+ Some(content)
+ }
+ Err(e) if e.kind() == ErrorKind::NotFound => {
+ // File doesn't exist, start with empty content
+ None
+ }
+ Err(e) => {
+ return Err(
+ anyhow::Error::from(e).context("Failed to read file from
remote storage")
+ );
+ }
+ };
+
+ // Get the editor command
+ let editor = env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
+
+ // Before launching the editor, we must ensure that our NamedTempFile
object is dropped,
+ // so that the editor has exclusive access and our later fs::read gets
the freshest content.
+ let temp_file_persister = temp_file.into_temp_path();
+
+ let status = Command::new(&editor)
+ .arg(&temp_path)
+ .status()
+ .context("Failed to start editor")?;
+
+ if !status.success() {
+ // Editor failed or exited with non-zero. We might want to offer
to save the temp file.
+ // For now, just error out.
+ // To save: temp_file_persister.keep().context("Failed to persist
temp file after editor failure")?;
+ return Err(anyhow::anyhow!(
+ "Editor exited with non-zero status: {}",
+ status
+ ));
+ }
+
+ // temp_file_persister is still in scope. If we do nothing, it will be
dropped and the file deleted.
+ // This is fine if we upload successfully. If upload fails, we need to
.keep() it.
+
+ let new_content_vec =
+ fs::read(&temp_path).context("Failed to read temporary file after
editing")?;
+
+ let content_changed = match &original_content_opendal_buffer {
+ Some(original_buffer) => {
+ original_buffer.to_vec().as_slice() !=
new_content_vec.as_slice()
+ }
+ None => !new_content_vec.is_empty(), // If original was None (new
file), changed if new content is not empty
+ };
+
+ if content_changed {
+ // Try to upload the modified content
+ match op.write(&path, new_content_vec.clone()).await {
+ Ok(_) => {
+ println!("File uploaded successfully to {}", path);
+ // Successfully uploaded, temp_file_persister can be
dropped, deleting the temp file.
+ }
+ Err(e) => {
+ // Upload failed, offer to save temp file
+ eprintln!("Error uploading file: {}", e);
+ let kept_path = temp_file_persister
+ .keep()
+ .context("Failed to preserve temporary file after
upload error")?;
+ eprintln!("Your changes have been saved to: {}",
kept_path.display());
+ eprintln!("You can retry uploading manually or re-edit.");
+ return Err(e.into()); // Propagate the original upload
error
+ }
+ }
+ } else {
+ println!("No changes detected.");
+ // No changes, temp_file_persister can be dropped, deleting the
temp file.
+ }
+
+ Ok(())
+ }
+}
diff --git a/bin/oli/src/commands/mod.rs b/bin/oli/src/commands/mod.rs
index be6b87a2f..1dd718255 100644
--- a/bin/oli/src/commands/mod.rs
+++ b/bin/oli/src/commands/mod.rs
@@ -20,6 +20,7 @@
pub mod bench;
pub mod cat;
pub mod cp;
+pub mod edit;
pub mod ls;
pub mod mv;
pub mod rm;
@@ -31,6 +32,7 @@ pub enum OliSubcommand {
Bench(bench::BenchCmd),
Cat(cat::CatCmd),
Cp(cp::CopyCmd),
+ Edit(edit::EditCmd),
Ls(ls::LsCmd),
Rm(rm::RmCmd),
Stat(stat::StatCmd),
@@ -44,6 +46,7 @@ impl OliSubcommand {
Self::Bench(cmd) => cmd.run(),
Self::Cat(cmd) => cmd.run(),
Self::Cp(cmd) => cmd.run(),
+ Self::Edit(cmd) => cmd.run(),
Self::Ls(cmd) => cmd.run(),
Self::Rm(cmd) => cmd.run(),
Self::Stat(cmd) => cmd.run(),
diff --git a/bin/oli/tests/integration/edit.rs
b/bin/oli/tests/integration/edit.rs
new file mode 100644
index 000000000..f968f13a2
--- /dev/null
+++ b/bin/oli/tests/integration/edit.rs
@@ -0,0 +1,280 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::env;
+use std::fs;
+
+use crate::test_utils::*;
+use anyhow::Result;
+
+/// Test helper that creates a mock editor script that doesn't modify files
+fn create_no_change_editor(dir: &std::path::Path) ->
Result<std::path::PathBuf> {
+ let editor_path = dir.join("no_change_editor.sh");
+ fs::write(
+ &editor_path,
+ "#!/bin/bash\n# Mock editor that exits successfully without modifying
the file\nexit 0\n",
+ )?;
+
+ // Make the script executable
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&editor_path)?.permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&editor_path, perms)?;
+ }
+
+ Ok(editor_path)
+}
+
+/// Test helper that creates a mock editor script that adds content to files
+fn create_modifying_editor(
+ dir: &std::path::Path,
+ content_to_add: &str,
+) -> Result<std::path::PathBuf> {
+ let editor_path = dir.join("modifying_editor.sh");
+ let script_content = format!(
+ "#!/bin/bash\n# Mock editor that adds content to the file\necho '{}'
>> \"$1\"\nexit 0\n",
+ content_to_add
+ );
+ fs::write(&editor_path, script_content)?;
+
+ // Make the script executable
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&editor_path)?.permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&editor_path, perms)?;
+ }
+
+ Ok(editor_path)
+}
+
+/// Test helper that creates a mock editor script that replaces file content
+fn create_replacing_editor(dir: &std::path::Path, new_content: &str) ->
Result<std::path::PathBuf> {
+ let editor_path = dir.join("replacing_editor.sh");
+ let script_content = format!(
+ "#!/bin/bash\n# Mock editor that replaces file content\necho '{}' >
\"$1\"\nexit 0\n",
+ new_content
+ );
+ fs::write(&editor_path, script_content)?;
+
+ // Make the script executable
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&editor_path)?.permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&editor_path, perms)?;
+ }
+
+ Ok(editor_path)
+}
+
+#[tokio::test]
+async fn test_edit_existing_file_no_changes() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("test_file.txt");
+ let original_content = "Hello, World!";
+ fs::write(&file_path, original_content)?;
+
+ // Create a mock editor that doesn't change the file
+ let editor_path = create_no_change_editor(dir.path())?;
+
+ // Set the EDITOR environment variable
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg(&file_path);
+
+ assert_cmd_snapshot!(cmd, @r#"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ No changes detected.
+
+ ----- stderr -----
+ "#);
+
+ // Verify the file content wasn't changed
+ let actual_content = fs::read_to_string(&file_path)?;
+ assert_eq!(original_content, actual_content);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_edit_existing_file_with_changes() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("test_file.txt");
+ let original_content = "Hello, World!";
+ fs::write(&file_path, original_content)?;
+
+ // Create a mock editor that adds content
+ let added_content = "Added line";
+ let editor_path = create_modifying_editor(dir.path(), added_content)?;
+
+ // Set the EDITOR environment variable
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg(&file_path);
+
+ assert_cmd_snapshot!(cmd, @r#"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ File uploaded successfully to tmp[TEMP_DIR]/test_file.txt
+
+ ----- stderr -----
+ "#);
+
+ // Verify the file content was changed
+ let actual_content = fs::read_to_string(&file_path)?;
+ assert!(actual_content.contains(original_content));
+ assert!(actual_content.contains(added_content));
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_edit_new_file() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("new_file.txt");
+
+ // Ensure the file doesn't exist
+ assert!(!file_path.exists());
+
+ // Create a mock editor that adds content to the new file
+ let new_content = "This is a new file";
+ let editor_path = create_replacing_editor(dir.path(), new_content)?;
+
+ // Set the EDITOR environment variable
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg(&file_path);
+
+ assert_cmd_snapshot!(cmd, @r#"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ File uploaded successfully to tmp[TEMP_DIR]/new_file.txt
+
+ ----- stderr -----
+ "#);
+
+ // Verify the file was created with the expected content
+ assert!(file_path.exists());
+ let actual_content = fs::read_to_string(&file_path)?;
+ assert_eq!(new_content.trim(), actual_content.trim());
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_edit_new_file_no_content() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("empty_new_file.txt");
+
+ // Ensure the file doesn't exist
+ assert!(!file_path.exists());
+
+ // Create a mock editor that doesn't add any content (leaves file empty)
+ let editor_path = create_no_change_editor(dir.path())?;
+
+ // Set the EDITOR environment variable
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg(&file_path);
+
+ assert_cmd_snapshot!(cmd, @r#"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ No changes detected.
+
+ ----- stderr -----
+ "#);
+
+ // Since the file would be empty and we detect no changes, it shouldn't be
uploaded
+ // The local file might exist but the remote shouldn't be created
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_edit_with_config_params() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("test_file.txt");
+ let original_content = "Hello with config!";
+ fs::write(&file_path, original_content)?;
+
+ // Create a mock editor that doesn't change the file
+ let editor_path = create_no_change_editor(dir.path())?;
+
+ // Test with config parameter (though we're using local fs, so config
might not apply)
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg("--config")
+ .arg("nonexistent_config.toml") // This should be handled gracefully
+ .arg(&file_path);
+
+ // The command might succeed or fail depending on config handling
+ // Let's just run it to see the behavior
+ let _output = cmd.output().unwrap();
+ // We don't assert success/failure here as it depends on config
implementation
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_edit_file_content_replacement() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let file_path = dir.path().join("replace_test.txt");
+ let original_content = "Original content that should be replaced";
+ fs::write(&file_path, original_content)?;
+
+ // Create a mock editor that completely replaces the content
+ let new_content = "Completely new content";
+ let editor_path = create_replacing_editor(dir.path(), new_content)?;
+
+ // Set the EDITOR environment variable
+ let mut cmd = oli();
+ cmd.env("EDITOR", editor_path.to_str().unwrap())
+ .arg("edit")
+ .arg(&file_path);
+
+ assert_cmd_snapshot!(cmd, @r#"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+ File uploaded successfully to tmp[TEMP_DIR]/replace_test.txt
+
+ ----- stderr -----
+ "#);
+
+ // Verify the file content was completely replaced
+ let actual_content = fs::read_to_string(&file_path)?;
+ assert_eq!(new_content.trim(), actual_content.trim());
+ assert!(!actual_content.contains("Original content"));
+
+ Ok(())
+}
diff --git a/bin/oli/tests/integration/main.rs
b/bin/oli/tests/integration/main.rs
index 6608329c3..300c57cb8 100644
--- a/bin/oli/tests/integration/main.rs
+++ b/bin/oli/tests/integration/main.rs
@@ -22,6 +22,7 @@
mod cat;
mod cp;
+mod edit;
mod ls;
mod mv;
mod rm;