git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit1f40e210b500191f8a68754c71e19c8ef607cad7
parent8bf3f236d0
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:59
Add Rename to the browse context menu

Right-clicking a row (outside the trash) offers Rename, which opens the
modal prompt prefilled with the name and the stem preselected. The
"Open with..." modal is generalized into a PromptDialog with a kind, so
both share layout, focus and key/mouse handling.

FsRequest::RenamePath refuses to replace an existing target (plain
rename silently clobbers), except for a case-only rename of the same
inode. Names must be bare: empty, ".", "..", and anything with '/' are
rejected. The list refreshes and selects the renamed entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/lib.rs          |   5 +-
 src/main.rs         | 153 ++++++++++++++++++++++++++++++++++++++--------------
 src/pages/browse.rs |  48 +++++++++++++++++
 src/services/fs.rs  |  49 +++++++++++++++++
 4 files changed, 213 insertions(+), 42 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 7c3ce77..de020bc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,7 +15,8 @@ pub enum Message {
     SelectOpen,
     SelectCancel,
     PromptOpenWith(std::path::PathBuf),
-    OpenWithSubmit,
-    OpenWithCancel,
+    PromptRename(std::path::PathBuf),
+    PromptSubmit,
+    PromptCancel,
     CopyPath(String),
 }
diff --git a/src/main.rs b/src/main.rs
index 28077d7..bfe4c71 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -37,18 +37,56 @@ fn context_menu_size(options: &[(String, Option<Message>)]) -> (f32, f32) {
     (w, h)
 }
 
-/// Computed rects for the "Open with…" modal, so layout and hit-testing agree.
-struct OpenWithRects {
+/// Computed rects for the prompt modal ("Open with…" / "Rename"), so layout
+/// and hit-testing agree.
+struct PromptRects {
     x: f32,
     y: f32,
     w: f32,
     h: f32,
     tb: (f32, f32, f32, f32),
     cancel: (f32, f32, f32, f32),
-    open: (f32, f32, f32, f32),
+    ok: (f32, f32, f32, f32),
 }
 
-fn open_with_rects(win_w: f32, win_h: f32) -> OpenWithRects {
+/// What a [`PromptDialog`] submits to.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum PromptKind {
+    /// Run the typed command on `path`.
+    OpenWith,
+    /// Rename `path` to the typed name, in place.
+    Rename,
+}
+
+impl PromptKind {
+    fn title(self) -> &'static str {
+        match self {
+            PromptKind::OpenWith => "Open with...",
+            PromptKind::Rename => "Rename",
+        }
+    }
+    fn prompt(self) -> &'static str {
+        match self {
+            PromptKind::OpenWith => "Enter command:",
+            PromptKind::Rename => "New name:",
+        }
+    }
+    fn ok_label(self) -> &'static str {
+        match self {
+            PromptKind::OpenWith => "Open",
+            PromptKind::Rename => "Rename",
+        }
+    }
+}
+
+/// The single-line modal prompt: one text well, Cancel + an OK button.
+struct PromptDialog {
+    kind: PromptKind,
+    path: std::path::PathBuf,
+    textbox: cce_ui::widget::Adapted<cce_ui::widget::TextBox>,
+}
+
+fn prompt_rects(win_w: f32, win_h: f32) -> PromptRects {
     let x = (win_w - DIALOG_W) / 2.0;
     let y = (win_h - DIALOG_H) / 2.0;
     let tb_h = cce_ui::layout::textbox_height();
@@ -61,7 +99,7 @@ fn open_with_rects(win_w: f32, win_h: f32) -> OpenWithRects {
     let (cancel_w, open_w) = (70.0, 80.0);
     let btn_y = y + DIALOG_H - btn_h - pad;
     let open_x = x + DIALOG_W - pad - open_w;
-    OpenWithRects {
+    PromptRects {
         x,
         y,
         w: DIALOG_W,
@@ -70,7 +108,7 @@ fn open_with_rects(win_w: f32, win_h: f32) -> OpenWithRects {
         // 42) is the dialog's own text rhythm, not a rung.
         tb: (x + pad, y + 60.0, DIALOG_W - 2.0 * pad, tb_h),
         cancel: (open_x - gap - cancel_w, btn_y, cancel_w, btn_h),
-        open: (open_x, btn_y, open_w, btn_h),
+        ok: (open_x, btn_y, open_w, btn_h),
     }
 }
 
@@ -449,7 +487,7 @@ struct FilesystemApp {
     /// `renderer_init`.
     seen_renderer: bool,
     context_menu: ContextMenu,
-    open_with_dialog: Option<(std::path::PathBuf, cce_ui::widget::Adapted<cce_ui::widget::TextBox>)>,
+    prompt_dialog: Option<PromptDialog>,
     browse_split: SplitPane,
     network_split: SplitPane,
     space_split: SplitPane,
@@ -616,7 +654,7 @@ impl FilesystemApp {
         self.network.breadcrumb.clear_children(&mut self.ui_context); self.network.breadcrumb.set_parent(None, &mut self.ui_context);
         self.network.graph.clear_children(&mut self.ui_context); self.network.graph.set_parent(None, &mut self.ui_context);
         self.space.breadcrumb.clear_children(&mut self.ui_context); self.space.breadcrumb.set_parent(None, &mut self.ui_context);
-        if let Some((_, textbox)) = &mut self.open_with_dialog {
+        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
             textbox.clear_children(&mut self.ui_context);
             textbox.set_parent(None, &mut self.ui_context);
         }
@@ -669,7 +707,7 @@ impl FilesystemApp {
             }
         }
 
-        if let Some((_, textbox)) = &mut self.open_with_dialog {
+        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
             self.ui_context.register_widget(textbox.base().id(), textbox.as_ptr_mut());
             textbox.set_parent(None, &mut self.ui_context);
         }
@@ -719,7 +757,7 @@ impl FilesystemApp {
                         (*self_ptr).preview.push_prims(&mut plain_pc);
                     }
                 }
-                if let Some((_, textbox)) = &mut (*self_ptr).open_with_dialog {
+                if let Some(PromptDialog { textbox, .. }) = &mut (*self_ptr).prompt_dialog {
                     let (x, y, w, h) = textbox.rect();
                     cce_ui::layout::render_widget(&mut plain_pc, textbox, x, y, w, h, &mut self.ui_context);
                 }
@@ -877,14 +915,15 @@ impl FilesystemApp {
             }
         }
 
-        // Gather open-with dialog backdrop & dialog panel if active (open_with_dialog uses textbox rendering manually but we can gather its other quads/texts)
+        // Gather the prompt dialog backdrop & panel if active (its textbox renders with the window widgets; this is everything else)
         let mut dialog_pc = pages::PageContent::new();
-        if let Some((_path, textbox)) = &mut self.open_with_dialog {
-            let r = open_with_rects(self.width as f32, self.height as f32);
+        if let Some(PromptDialog { kind, textbox, .. }) = &mut self.prompt_dialog {
+            let kind = *kind;
+            let r = prompt_rects(self.width as f32, self.height as f32);
             let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
             let (tb_x, tb_y, tb_w, tb_h) = r.tb;
             let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
-            let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.open;
+            let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.ok;
 
             // Semi-transparent backdrop overlay
             dialog_pc.rect([0.02, 0.02, 0.03, 0.6], 0.0, 0.0, self.width as f32, self.height as f32);
@@ -894,11 +933,11 @@ impl FilesystemApp {
             dialog_pc.rect([0.08, 0.08, 0.12, 1.0], dialog_x + 1.0, dialog_y + 1.0, dialog_w - 2.0, dialog_h - 2.0);
 
             // Title and prompt, inset from the plate rim by the pane rung
-            // (the same inset the well and buttons take in open_with_rects).
+            // (the same inset the well and buttons take in prompt_rects).
             let dialog_pad = cce_ui::layout::plate_padding();
-            dialog_pc.text("Open with...", dialog_x + dialog_pad, dialog_y + dialog_pad, 14.0, [1.0, 1.0, 1.0, 1.0]);
+            dialog_pc.text(kind.title(), dialog_x + dialog_pad, dialog_y + dialog_pad, 14.0, [1.0, 1.0, 1.0, 1.0]);
             // TODO(style): the prompt's 42px drop is the dialog's text rhythm.
-            dialog_pc.text("Enter command:", dialog_x + dialog_pad, dialog_y + 42.0, 11.0, [0.54, 0.54, 0.58, 1.0]);
+            dialog_pc.text(kind.prompt(), dialog_x + dialog_pad, dialog_y + 42.0, 11.0, [0.54, 0.54, 0.58, 1.0]);
 
             // Set textbox position dynamically using configured textbox height
             textbox.set_rect(tb_x, tb_y, tb_w, tb_h);
@@ -909,8 +948,8 @@ impl FilesystemApp {
             // Plain toolkit faces, like the chooser footer: the themed Button
             // owns its own hover state, so the hand-rolled cursor tracking and
             // the red/green tints go together.
-            dialog_pc.button_plain("Cancel", btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h, Message::OpenWithCancel);
-            dialog_pc.button_plain("Open", btn_open_x, btn_open_y, btn_open_w, btn_open_h, Message::OpenWithSubmit);
+            dialog_pc.button_plain("Cancel", btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h, Message::PromptCancel);
+            dialog_pc.button_plain(kind.ok_label(), btn_open_x, btn_open_y, btn_open_w, btn_open_h, Message::PromptSubmit);
         }
 
         // Translate everything into widgets and text_items!
@@ -1204,7 +1243,7 @@ impl Application for FilesystemApp {
 
     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
         // 1. If dialog is open, do not drag
-        if self.open_with_dialog.is_some() {
+        if self.prompt_dialog.is_some() {
             return false;
         }
         // 2. If context menu is visible, do not drag
@@ -1336,7 +1375,7 @@ impl Application for FilesystemApp {
                 options: Vec::new(),
                 hovered: None,
             },
-            open_with_dialog: None,
+            prompt_dialog: None,
             browse_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
             network_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
             space_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
@@ -1541,26 +1580,57 @@ impl Application for FilesystemApp {
                     .with_placeholder("Program/Command");
                 tb.focus();
                 self.ui_context.set_focused(&mut tb);
-                self.open_with_dialog = Some((path, tb));
+                self.prompt_dialog = Some(PromptDialog { kind: PromptKind::OpenWith, path, textbox: tb });
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
-            Message::OpenWithSubmit => {
-                if let Some((path, textbox)) = self.open_with_dialog.take() {
-                    let cmd_str = if textbox.editing {
+            Message::PromptRename(path) => {
+                let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+                // Preselect the stem, not the extension, so typing replaces
+                // just the part people usually mean to change.
+                let stem_chars = if path.is_dir() {
+                    name.chars().count()
+                } else {
+                    path.file_stem().map(|s| s.to_string_lossy().chars().count()).unwrap_or(0)
+                };
+                let mut tb = cce_ui::widget::TextBox::new(name).with_max_width(None);
+                tb.focus();
+                tb.cursor_idx = stem_chars;
+                tb.select_anchor = Some(0);
+                tb.all_selected = false;
+                tb.sync_editor_state();
+                self.ui_context.set_focused(&mut tb);
+                self.prompt_dialog = Some(PromptDialog { kind: PromptKind::Rename, path, textbox: tb });
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+            Message::PromptSubmit => {
+                if let Some(PromptDialog { kind, path, textbox }) = self.prompt_dialog.take() {
+                    let value = if textbox.editing {
                         textbox.edit_buffer.trim().to_string()
                     } else {
                         textbox.text.trim().to_string()
                     };
-                    if !cmd_str.is_empty() {
-                        crate::services::fs::spawn_command_for_path(&cmd_str, &path);
+                    match kind {
+                        PromptKind::OpenWith => {
+                            if !value.is_empty() {
+                                crate::services::fs::spawn_command_for_path(&value, &path);
+                            }
+                        }
+                        PromptKind::Rename => {
+                            self.update(
+                                Message::Browse(pages::browse::BrowseMessage::RenameEntry(path, value)),
+                                needs_rebuild,
+                                _exit,
+                            );
+                        }
                     }
                 }
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
-            Message::OpenWithCancel => {
-                self.open_with_dialog = None;
+            Message::PromptCancel => {
+                self.prompt_dialog = None;
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1793,8 +1863,8 @@ impl Application for FilesystemApp {
 
         // Routed dispatch (6bd shrink): one Event through the router per targeted root.
         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
-        if self.open_with_dialog.is_some() {
-            if let Some((_path, textbox)) = &mut self.open_with_dialog {
+        if self.prompt_dialog.is_some() {
+            if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
                 let root = textbox.id();
                 let _ = self.ui_context.propagate_event(&mv, root);
             }
@@ -2006,19 +2076,19 @@ impl Application for FilesystemApp {
             return None;
         }
 
-        if let Some((_path, textbox)) = &mut self.open_with_dialog {
-            let r = open_with_rects(self.width as f32, self.height as f32);
+        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
+            let r = prompt_rects(self.width as f32, self.height as f32);
             let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
             let (tb_x, tb_y, tb_w, tb_h) = r.tb;
             let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
-            let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.open;
+            let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.ok;
 
             if state == ElementState::Pressed {
                 let clicked_inside = pos.x >= dialog_x && pos.x <= dialog_x + dialog_w && pos.y >= dialog_y && pos.y <= dialog_y + dialog_h;
                 if !clicked_inside {
                     textbox.unfocus();
                     self.ui_context.clear_focus();
-                    return Some(Message::OpenWithCancel);
+                    return Some(Message::PromptCancel);
                 }
 
                 if button == MouseButton::Left {
@@ -2033,11 +2103,11 @@ impl Application for FilesystemApp {
                     } else if pos.x >= btn_cancel_x && pos.x <= btn_cancel_x + btn_cancel_w && pos.y >= btn_cancel_y && pos.y <= btn_cancel_y + btn_cancel_h {
                         textbox.unfocus();
                         self.ui_context.clear_focus();
-                        return Some(Message::OpenWithCancel);
+                        return Some(Message::PromptCancel);
                     } else if pos.x >= btn_open_x && pos.x <= btn_open_x + btn_open_w && pos.y >= btn_open_y && pos.y <= btn_open_y + btn_open_h {
                         textbox.unfocus();
                         self.ui_context.clear_focus();
-                        return Some(Message::OpenWithSubmit);
+                        return Some(Message::PromptSubmit);
                     } else {
                         textbox.unfocus();
                         self.ui_context.clear_focus();
@@ -2145,6 +2215,9 @@ impl Application for FilesystemApp {
                         }
 
                         options.push(("Open with...".to_string(), Some(Message::PromptOpenWith(entry.path.clone()))));
+                        if !is_trash_dir {
+                            options.push(("Rename".to_string(), Some(Message::PromptRename(entry.path.clone()))));
+                        }
 
                         if is_trash_dir {
                             options.push(("Restore".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::RestoreEntry(idx)))));
@@ -2553,16 +2626,16 @@ impl Application for FilesystemApp {
             return None;
         }
 
-        if let Some((_path, textbox)) = &mut self.open_with_dialog {
+        if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
             if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) {
                 textbox.unfocus();
                 self.ui_context.clear_focus();
-                return Some(Message::OpenWithSubmit);
+                return Some(Message::PromptSubmit);
             }
             if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) {
                 textbox.unfocus();
                 self.ui_context.clear_focus();
-                return Some(Message::OpenWithCancel);
+                return Some(Message::PromptCancel);
             }
             let kev = cce_ui::widget::Event::KeyInput(event.clone());
             let root = textbox.id();
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index dd189fe..d9a045f 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -106,6 +106,10 @@ pub enum BrowseMessage {
     NewFolder,
     /// (parent dir, the created folder or the error).
     FolderCreated(PathBuf, Result<PathBuf, String>),
+    /// Rename the entry at this path to the given bare name, in place.
+    RenameEntry(PathBuf, String),
+    /// (old path, the new path or the error).
+    Renamed(PathBuf, Result<PathBuf, String>),
     Deleted(PathBuf, Result<(), String>),
     TrashEmptied(Result<(), String>),
     LastDirLoaded(Option<PathBuf>),
@@ -151,6 +155,23 @@ pub fn path_to_segment(current_dir: &Path, seg: usize) -> PathBuf {
 
 // ── Helpers ─────────────────────────────────────────────────────────
 
+/// The target path for renaming `path` to `new_name`, or `Ok(None)` when the
+/// name is unchanged. Only bare names are accepted — a rename never moves an
+/// entry to another directory.
+pub fn validate_new_name(path: &Path, new_name: &str) -> Result<Option<PathBuf>, String> {
+    if new_name.is_empty() || new_name == "." || new_name == ".." {
+        return Err(format!("\"{new_name}\" is not a valid name"));
+    }
+    if new_name.contains('/') || new_name.contains('\0') {
+        return Err("names cannot contain '/'".to_string());
+    }
+    if path.file_name().is_some_and(|n| n == new_name) {
+        return Ok(None);
+    }
+    let parent = path.parent().ok_or_else(|| "cannot rename the root".to_string())?;
+    Ok(Some(parent.join(new_name)))
+}
+
 pub fn read_directory(path: &Path) -> Vec<DirEntry> {
     crate::services::fs::read_directory_internal(path)
 }
@@ -498,6 +519,23 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
             }
             Some(crate::services::fs::FsRequest::ReadDirectory(state.current_dir.clone()))
         }
+        BrowseMessage::RenameEntry(path, new_name) => {
+            match validate_new_name(&path, &new_name) {
+                Ok(Some(to)) => Some(crate::services::fs::FsRequest::RenamePath(path, to)),
+                Ok(None) => None,
+                Err(e) => {
+                    log::error!("Cannot rename {}: {}", path.display(), e);
+                    None
+                }
+            }
+        }
+        BrowseMessage::Renamed(from, result) => {
+            match result {
+                Ok(to) => state.pending_select = Some(to),
+                Err(e) => log::error!("Failed to rename {}: {}", from.display(), e),
+            }
+            Some(crate::services::fs::FsRequest::RefreshDirectory(state.current_dir.clone()))
+        }
         BrowseMessage::Deleted(path, result) => {
             match result {
                 Ok(_) => {
@@ -892,6 +930,16 @@ mod tests {
         let _ = std::fs::remove_dir_all(&test_subdir);
     }
 
+    #[test]
+    fn test_validate_new_name() {
+        let path = PathBuf::from("/home/user/notes.txt");
+        assert_eq!(validate_new_name(&path, "todo.md"), Ok(Some(PathBuf::from("/home/user/todo.md"))));
+        assert_eq!(validate_new_name(&path, "notes.txt"), Ok(None));
+        for bad in ["", ".", "..", "a/b", "../x"] {
+            assert!(validate_new_name(&path, bad).is_err(), "{bad:?} should be rejected");
+        }
+    }
+
     #[test]
     fn test_component_reconstruction() {
         let current_dir = PathBuf::from("/home/user/documents");
diff --git a/src/services/fs.rs b/src/services/fs.rs
index a0adc9e..9f56d69 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -42,6 +42,8 @@ pub enum FsRequest {
     EmptyTrash,
     /// Create a new, uniquely named folder inside the given directory.
     CreateDir(PathBuf),
+    /// Rename (from, to) — refused when `to` already exists.
+    RenamePath(PathBuf, PathBuf),
     /// Walk a whole subtree for the Space view. The flag is the caller's
     /// cancel token — raising it abandons a scan whose answer is no longer
     /// wanted (see `SpaceState::begin_scan`).
@@ -133,6 +135,14 @@ impl FsService {
                             ));
                         });
                     }
+                    FsRequest::RenamePath(from, to) => {
+                        tokio::spawn(async move {
+                            let result = rename_no_clobber(&from, &to).map(|_| to).map_err(|e| e.to_string());
+                            let _ = app_sender.send(crate::Message::Browse(
+                                crate::pages::browse::BrowseMessage::Renamed(from, result),
+                            ));
+                        });
+                    }
                     FsRequest::ScanTree(path, cancel) => {
                         // Minutes of blocking recursion on a large tree, so
                         // this goes to the blocking pool rather than tying up
@@ -204,6 +214,23 @@ impl FsService {
 
 // ── Internal Helper Functions ───────────────────────────────────────
 
+/// `fs::rename`, except it will not replace an existing `to` — plain rename
+/// silently overwrites a file of the same name.
+pub fn rename_no_clobber(from: &Path, to: &Path) -> std::io::Result<()> {
+    use std::os::unix::fs::MetadataExt;
+    // The same inode is a case-only rename on a case-insensitive mount, not a clobber.
+    let same_file = |a: &fs::Metadata, b: &fs::Metadata| a.dev() == b.dev() && a.ino() == b.ino();
+    if let Ok(existing) = fs::symlink_metadata(to)
+        && !fs::symlink_metadata(from).is_ok_and(|m| same_file(&m, &existing))
+    {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::AlreadyExists,
+            format!("{} already exists", to.display()),
+        ));
+    }
+    fs::rename(from, to)
+}
+
 /// Create "New Folder" in `parent`, or "New Folder 2", "New Folder 3", … when
 /// the name is taken. `create_dir` itself is the existence check, so a name
 /// claimed between two attempts just moves on to the next one.
@@ -732,6 +759,28 @@ pub fn open_file(path: &Path) {
 mod tests {
     use super::*;
 
+    #[test]
+    fn test_rename_no_clobber() {
+        let dir = std::env::temp_dir().join(format!(
+            "cce_test_rename_{}",
+            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+        let (a, b, c) = (dir.join("a.txt"), dir.join("b.txt"), dir.join("c.txt"));
+        fs::write(&a, "a").unwrap();
+        fs::write(&b, "b").unwrap();
+
+        // An existing target is refused and left untouched.
+        assert!(rename_no_clobber(&a, &b).is_err());
+        assert_eq!(fs::read_to_string(&b).unwrap(), "b");
+
+        rename_no_clobber(&a, &c).unwrap();
+        assert!(!a.exists());
+        assert_eq!(fs::read_to_string(&c).unwrap(), "a");
+
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     #[test]
     fn test_create_new_folder_picks_unique_names() {
         let dir = std::env::temp_dir().join(format!(