Skip to main content

void_core/workspace/
move_path.rs

1//! Move/rename operations for workspace.
2
3use std::fs;
4
5use crate::VoidContext;
6use crate::index::{index_entry_from_file, write_workspace_index, IndexEntry};
7use crate::{Result, VoidError};
8
9use super::stage::load_index_or_empty;
10
11/// Options for moving/renaming paths.
12#[derive(Clone)]
13pub struct MoveOptions {
14    pub ctx: VoidContext,
15    pub source: String,
16    pub dest: String,
17}
18
19/// Result of moving a path.
20#[derive(Debug, Clone)]
21pub struct MoveResult {
22    pub from: String,
23    pub to: String,
24}
25
26/// Move/rename a file in the index and on disk.
27///
28/// # Arguments
29/// * `opts` - Move options including source and destination paths.
30///
31/// # Behavior
32/// - Renames file on disk
33/// - Updates index: removes old path, adds new path with same content hash
34/// - Equivalent to: rm old + add new (but preserves content hash for rename detection)
35pub fn move_path(opts: MoveOptions) -> Result<MoveResult> {
36    let root = &opts.ctx.paths.root;
37
38    let mut index = load_index_or_empty(&opts.ctx)?;
39
40    // Normalize paths
41    let source = opts
42        .source
43        .replace('\\', "/")
44        .trim_start_matches('/')
45        .to_string();
46    let mut dest = opts
47        .dest
48        .replace('\\', "/")
49        .trim_start_matches('/')
50        .to_string();
51
52    // Check source exists in index
53    let source_entry = index.get(&source).cloned().ok_or_else(|| {
54        VoidError::NotFound(format!("pathspec '{}' did not match any files", source))
55    })?;
56
57    // Validate and check source exists on disk (prevent path traversal)
58    let source_path = crate::util::safe_join(opts.ctx.paths.root.as_std_path(), &source)?;
59    if !source_path.exists() {
60        return Err(VoidError::NotFound(format!(
61            "source file '{}' does not exist",
62            source
63        )));
64    }
65
66    // Validate and determine final destination path (prevent path traversal)
67    let dest_path = crate::util::safe_join(opts.ctx.paths.root.as_std_path(), &dest)?;
68
69    // If dest is a directory, move into it with same filename
70    if dest_path.is_dir() {
71        let filename = source
72            .rsplit('/')
73            .next()
74            .ok_or_else(|| VoidError::InvalidPattern("invalid source path".to_string()))?;
75        dest = format!("{}/{}", dest.trim_end_matches('/'), filename);
76    }
77
78    // Validate final destination path (prevent path traversal)
79    let final_dest_path = crate::util::safe_join(opts.ctx.paths.root.as_std_path(), &dest)?;
80
81    // Check dest doesn't already exist (unless it's being overwritten)
82    if final_dest_path.exists() && final_dest_path != source_path {
83        // Check if dest is in the index
84        if index.get(&dest).is_some() {
85            return Err(VoidError::InvalidPattern(format!(
86                "destination '{}' already exists in index",
87                dest
88            )));
89        }
90    }
91
92    // Ensure destination directory exists
93    if let Some(parent) = final_dest_path.parent() {
94        fs::create_dir_all(parent)?;
95    }
96
97    // Rename file on disk
98    fs::rename(&source_path, &final_dest_path)?;
99
100    // Update index: remove old path
101    index.entries.retain(|e| e.path != source);
102
103    // Add new path with same content hash but updated metadata
104    let new_entry = index_entry_from_file(root, &dest)?;
105    let entry = IndexEntry {
106        path: dest.clone(),
107        content_hash: source_entry.content_hash, // Preserve for rename detection
108        mtime_secs: new_entry.mtime_secs,
109        mtime_nanos: new_entry.mtime_nanos,
110        size: new_entry.size,
111    };
112    index.upsert_entry(entry);
113
114    write_workspace_index(opts.ctx.paths.workspace_dir.as_std_path(), opts.ctx.crypto.vault.index_key()?, &index)?;
115
116    Ok(MoveResult {
117        from: source,
118        to: dest,
119    })
120}