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;

Reply via email to