Skip to main content

command_stream/commands/
mv.rs

1//! Virtual `mv` command implementation
2
3use crate::commands::CommandContext;
4use crate::utils::{trace_lazy, CommandResult, VirtualUtils};
5use std::fs;
6
7/// Execute the mv command
8///
9/// Moves (renames) files and directories.
10pub async fn mv(ctx: CommandContext) -> CommandResult {
11    if ctx.args.len() < 2 {
12        return VirtualUtils::invalid_argument_error("mv", "missing file operand");
13    }
14
15    // Parse flags (currently just skip them)
16    let mut paths = Vec::new();
17
18    for arg in &ctx.args {
19        if !arg.starts_with('-') {
20            paths.push(arg.clone());
21        }
22    }
23
24    if paths.len() < 2 {
25        return VirtualUtils::invalid_argument_error("mv", "missing destination file operand");
26    }
27
28    let cwd = ctx.get_cwd();
29    let dest = paths.pop().unwrap();
30    let dest_path = VirtualUtils::resolve_path(&dest, Some(&cwd));
31
32    // If multiple sources or dest is a directory, move into the directory
33    let dest_is_dir = dest_path.is_dir();
34    let multiple_sources = paths.len() > 1;
35
36    if multiple_sources && !dest_is_dir {
37        return CommandResult::error(format!("mv: target '{}' is not a directory\n", dest));
38    }
39
40    for source in paths {
41        let source_path = VirtualUtils::resolve_path(&source, Some(&cwd));
42
43        trace_lazy("VirtualCommand", || {
44            format!("mv: moving {:?} to {:?}", source_path, dest_path)
45        });
46
47        if !source_path.exists() {
48            return CommandResult::error(format!(
49                "mv: cannot stat '{}': No such file or directory\n",
50                source
51            ));
52        }
53
54        let final_dest = if dest_is_dir {
55            dest_path.join(source_path.file_name().unwrap_or_default())
56        } else {
57            dest_path.clone()
58        };
59
60        // Try rename first (fastest if on same filesystem)
61        match fs::rename(&source_path, &final_dest) {
62            Ok(()) => continue,
63            Err(e) => {
64                // If rename fails (e.g., cross-filesystem), try copy + delete
65                if e.kind() == std::io::ErrorKind::CrossesDevices
66                    || e.kind() == std::io::ErrorKind::Other
67                {
68                    if source_path.is_dir() {
69                        if let Err(e) = copy_and_remove_dir(&source_path, &final_dest) {
70                            return CommandResult::error(format!(
71                                "mv: cannot move '{}': {}\n",
72                                source, e
73                            ));
74                        }
75                    } else {
76                        if let Err(e) = fs::copy(&source_path, &final_dest) {
77                            return CommandResult::error(format!(
78                                "mv: cannot move '{}': {}\n",
79                                source, e
80                            ));
81                        }
82                        if let Err(e) = fs::remove_file(&source_path) {
83                            return CommandResult::error(format!(
84                                "mv: cannot remove '{}': {}\n",
85                                source, e
86                            ));
87                        }
88                    }
89                } else {
90                    return CommandResult::error(format!("mv: cannot move '{}': {}\n", source, e));
91                }
92            }
93        }
94    }
95
96    CommandResult::success_empty()
97}
98
99fn copy_and_remove_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
100    fs::create_dir_all(dst)?;
101
102    for entry in fs::read_dir(src)? {
103        let entry = entry?;
104        let entry_path = entry.path();
105        let dest_path = dst.join(entry.file_name());
106
107        if entry_path.is_dir() {
108            copy_and_remove_dir(&entry_path, &dest_path)?;
109        } else {
110            fs::copy(&entry_path, &dest_path)?;
111        }
112    }
113
114    fs::remove_dir_all(src)?;
115    Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use tempfile::tempdir;
122
123    #[tokio::test]
124    async fn test_mv_file() {
125        let temp = tempdir().unwrap();
126        let src = temp.path().join("source.txt");
127        let dst = temp.path().join("dest.txt");
128        fs::write(&src, "test content").unwrap();
129
130        let ctx = CommandContext::new(vec![
131            src.to_string_lossy().to_string(),
132            dst.to_string_lossy().to_string(),
133        ]);
134        let result = mv(ctx).await;
135
136        assert!(result.is_success());
137        assert!(!src.exists());
138        assert!(dst.exists());
139        assert_eq!(fs::read_to_string(&dst).unwrap(), "test content");
140    }
141
142    #[tokio::test]
143    async fn test_mv_directory() {
144        let temp = tempdir().unwrap();
145        let src_dir = temp.path().join("src_dir");
146        let dst_dir = temp.path().join("dst_dir");
147
148        fs::create_dir(&src_dir).unwrap();
149        fs::write(src_dir.join("file.txt"), "test").unwrap();
150
151        let ctx = CommandContext::new(vec![
152            src_dir.to_string_lossy().to_string(),
153            dst_dir.to_string_lossy().to_string(),
154        ]);
155        let result = mv(ctx).await;
156
157        assert!(result.is_success());
158        assert!(!src_dir.exists());
159        assert!(dst_dir.join("file.txt").exists());
160    }
161
162    #[tokio::test]
163    async fn test_mv_nonexistent() {
164        let temp = tempdir().unwrap();
165
166        let ctx = CommandContext::new(vec![
167            "/nonexistent/file".to_string(),
168            temp.path().join("dest").to_string_lossy().to_string(),
169        ]);
170        let result = mv(ctx).await;
171
172        assert!(!result.is_success());
173    }
174}