gestura_core_explorer/
explorer.rs

1//! Workspace file explorer helpers.
2//!
3//! Platform-independent business logic for listing directories, validating
4//! relative paths, and normalising git-porcelain change paths.  GUI and CLI
5//! presentation layers should delegate here and only add transport-specific
6//! wrappers (Tauri commands, TUI rendering, …).
7
8use serde::Serialize;
9use std::collections::HashMap;
10use std::path::{Component, Path, PathBuf};
11
12// ---------------------------------------------------------------------------
13// Error
14// ---------------------------------------------------------------------------
15
16/// Errors returned by the explorer helpers.
17#[derive(Debug, thiserror::Error)]
18pub enum ExplorerError {
19    /// The workspace root directory has not been configured.
20    #[error("workspace root is not set")]
21    MissingRoot,
22    /// The supplied relative path is syntactically invalid (absolute, contains
23    /// `..`, etc.).
24    #[error("invalid relative path: {0}")]
25    InvalidRelPath(String),
26    /// The resolved path escapes the workspace root (e.g. via symlinks).
27    #[error("path escapes workspace root")]
28    PathEscapesRoot,
29    /// The resolved path does not point to a directory.
30    #[error("not a directory: {0}")]
31    NotADirectory(String),
32    /// Underlying I/O error.
33    #[error(transparent)]
34    Io(#[from] std::io::Error),
35}
36
37// ---------------------------------------------------------------------------
38// Types
39// ---------------------------------------------------------------------------
40
41/// Kind of a directory entry.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ExplorerEntryKind {
45    /// Regular file.
46    File,
47    /// Directory.
48    Dir,
49}
50
51/// A single entry returned by [`list_dir`].
52#[derive(Debug, Clone, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub struct ExplorerEntry {
55    /// File/directory name (leaf component only).
56    pub name: String,
57    /// Forward-slash separated path relative to the workspace root.
58    pub rel_path: String,
59    /// Whether this entry is a file or directory.
60    pub kind: ExplorerEntryKind,
61    /// Whether the entry is a symbolic link.
62    pub is_symlink: bool,
63}
64
65/// Response payload for a directory listing.
66#[derive(Debug, Clone, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub struct ExplorerListDirResponse {
69    /// Absolute path of the workspace root.
70    pub root: String,
71    /// Relative directory that was listed.
72    pub dir_rel: String,
73    /// Entries in the directory.
74    pub entries: Vec<ExplorerEntry>,
75    /// `true` when `max_entries` was reached before the full listing.
76    pub truncated: bool,
77}
78
79/// Response payload for the workspace root query.
80#[derive(Debug, Clone, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub struct ExplorerRootResponse {
83    /// Absolute path of the workspace root.
84    pub root: String,
85    /// Whether a `.git` directory exists at the root.
86    pub is_git_repo: bool,
87}
88
89/// Kind of change reported by `git status --porcelain`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ExplorerGitChangeKind {
93    /// Newly added.
94    Added,
95    /// Modified content.
96    Modified,
97    /// Deleted.
98    Deleted,
99    /// Renamed.
100    Renamed,
101    /// Copied.
102    Copied,
103    /// Untracked (only in `unstaged`).
104    Untracked,
105    /// Unknown status character.
106    Unknown,
107}
108
109/// Combined staged/unstaged/untracked status for a single path.
110#[derive(Debug, Clone, Default, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub struct ExplorerGitPathStatus {
113    /// Staged index change kind (if any).
114    pub staged: Option<ExplorerGitChangeKind>,
115    /// Unstaged worktree change kind (if any).
116    pub unstaged: Option<ExplorerGitChangeKind>,
117    /// `true` when the path is untracked.
118    pub untracked: bool,
119}
120
121/// Response payload for the git-status query.
122#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub struct ExplorerGitStatusResponse {
125    /// Absolute path of the workspace root.
126    pub root: String,
127    /// Whether a `.git` directory exists at the root.
128    pub is_git_repo: bool,
129    /// Per-path statuses (key = forward-slash relative path).
130    pub paths: HashMap<String, ExplorerGitPathStatus>,
131    /// Non-fatal error message (e.g. `git` not installed).
132    pub error: Option<String>,
133}
134
135// ---------------------------------------------------------------------------
136// Functions
137// ---------------------------------------------------------------------------
138
139/// Validate that `rel` is a safe, non-escaping relative path.
140///
141/// Returns the normalised [`PathBuf`] on success, or [`ExplorerError::InvalidRelPath`]
142/// if the path is absolute or contains `..` / prefix components.
143pub fn ensure_safe_rel_path(rel: &str) -> Result<PathBuf, ExplorerError> {
144    let rel = rel.trim();
145    if rel.is_empty() {
146        return Ok(PathBuf::new());
147    }
148
149    let p = Path::new(rel);
150    if p.is_absolute() {
151        return Err(ExplorerError::InvalidRelPath(rel.to_string()));
152    }
153
154    for c in p.components() {
155        match c {
156            Component::CurDir => {}
157            Component::Normal(_) => {}
158            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
159                return Err(ExplorerError::InvalidRelPath(rel.to_string()));
160            }
161        }
162    }
163
164    Ok(p.to_path_buf())
165}
166
167/// Convert a [`Path`] into a forward-slash separated string, keeping only
168/// `Normal` components.
169fn path_to_slash_string(p: &Path) -> String {
170    p.components()
171        .filter_map(|c| match c {
172            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
173            _ => None,
174        })
175        .collect::<Vec<_>>()
176        .join("/")
177}
178
179/// Normalize a git porcelain path into a safe `rel_path` string.
180///
181/// - Handles rename/copy entries that contain `old -> new` by keeping the **new** path.
182/// - Strips surrounding quotes.
183/// - Rejects any path that isn't a safe relative path (e.g. contains `..`).
184pub fn normalize_git_change_path(path: &Path) -> Option<String> {
185    let raw = path.to_string_lossy();
186    let raw = raw.trim();
187    if raw.is_empty() {
188        return None;
189    }
190
191    let raw = raw.trim_matches('"');
192    let raw = match raw.rsplit_once(" -> ") {
193        Some((_, new)) => new.trim(),
194        None => raw,
195    };
196
197    let safe = ensure_safe_rel_path(raw).ok()?;
198    Some(path_to_slash_string(&safe))
199}
200
201/// Canonicalize the workspace root path.
202pub fn canonical_root(root: &Path) -> Result<PathBuf, ExplorerError> {
203    Ok(std::fs::canonicalize(root)?)
204}
205
206/// Resolve `rel` under `root`, ensuring the result stays within the root
207/// after symlink resolution.
208pub fn resolve_under_root(root: &Path, rel: &str) -> Result<PathBuf, ExplorerError> {
209    let rel = ensure_safe_rel_path(rel)?;
210    let root_canon = canonical_root(root)?;
211    let joined = root_canon.join(rel);
212    let target = std::fs::canonicalize(&joined)?;
213    if !target.starts_with(&root_canon) {
214        return Err(ExplorerError::PathEscapesRoot);
215    }
216    Ok(target)
217}
218
219/// List directory entries under `root/dir_rel`, returning at most `max_entries`.
220///
221/// Returns `(entries, truncated)` where `truncated` is `true` when the listing
222/// was cut short.  Entries are sorted directories-first, then
223/// case-insensitively by name.  Symlinks that escape the workspace root are
224/// silently omitted.
225pub fn list_dir(
226    root: &Path,
227    dir_rel: &str,
228    max_entries: usize,
229) -> Result<(Vec<ExplorerEntry>, bool), ExplorerError> {
230    let dir_rel = dir_rel.trim();
231
232    let root_canon = canonical_root(root)?;
233    let dir_path = if dir_rel.is_empty() {
234        root_canon.clone()
235    } else {
236        resolve_under_root(&root_canon, dir_rel)?
237    };
238
239    if !dir_path.is_dir() {
240        return Err(ExplorerError::NotADirectory(dir_path.display().to_string()));
241    }
242
243    let mut entries = Vec::new();
244    let mut truncated = false;
245
246    for (i, e) in std::fs::read_dir(&dir_path)?.enumerate() {
247        if i >= max_entries {
248            truncated = true;
249            break;
250        }
251        let e = e?;
252        let name = e.file_name().to_string_lossy().to_string();
253        if name.is_empty() {
254            continue;
255        }
256
257        let ft = e.file_type()?;
258        let is_symlink = ft.is_symlink();
259
260        // Follow symlinks only enough to determine dir/file, but never allow escape.
261        let kind = if ft.is_symlink() {
262            match std::fs::canonicalize(e.path()) {
263                Ok(target) if target.starts_with(&root_canon) => {
264                    if target.is_dir() {
265                        ExplorerEntryKind::Dir
266                    } else {
267                        ExplorerEntryKind::File
268                    }
269                }
270                Ok(_) => {
271                    // Symlink escapes root; drop it from the listing entirely.
272                    continue;
273                }
274                Err(_) => ExplorerEntryKind::File,
275            }
276        } else if ft.is_dir() {
277            ExplorerEntryKind::Dir
278        } else {
279            ExplorerEntryKind::File
280        };
281
282        let rel_path = if dir_rel.is_empty() {
283            name.clone()
284        } else {
285            path_to_slash_string(Path::new(dir_rel).join(&name).as_path())
286        };
287
288        entries.push(ExplorerEntry {
289            name,
290            rel_path,
291            kind,
292            is_symlink,
293        });
294    }
295
296    entries.sort_by(|a, b| {
297        let a_dir = a.kind == ExplorerEntryKind::Dir;
298        let b_dir = b.kind == ExplorerEntryKind::Dir;
299        match (a_dir, b_dir) {
300            (true, false) => std::cmp::Ordering::Less,
301            (false, true) => std::cmp::Ordering::Greater,
302            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
303        }
304    });
305
306    Ok((entries, truncated))
307}
308
309// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn rejects_parent_dir_traversal() {
319        assert!(ensure_safe_rel_path("../secret").is_err());
320        assert!(ensure_safe_rel_path("a/../../b").is_err());
321    }
322
323    #[test]
324    fn allows_empty_and_normal_paths() {
325        assert_eq!(ensure_safe_rel_path("").unwrap(), PathBuf::new());
326        assert_eq!(
327            ensure_safe_rel_path("src/lib.rs").unwrap(),
328            PathBuf::from("src/lib.rs")
329        );
330    }
331
332    #[cfg(unix)]
333    #[test]
334    fn drops_symlinks_that_escape_root() {
335        use std::fs;
336        use std::os::unix::fs::symlink;
337
338        let tmp = tempfile::tempdir().unwrap();
339        let root = tmp.path().join("root");
340        let outside = tmp.path().join("outside");
341        fs::create_dir_all(&root).unwrap();
342        fs::create_dir_all(&outside).unwrap();
343
344        symlink(&outside, root.join("escape")).unwrap();
345        fs::write(root.join("ok.txt"), "ok").unwrap();
346
347        let (entries, _) = list_dir(&root, "", 100).unwrap();
348        assert!(entries.iter().any(|e| e.name == "ok.txt"));
349        assert!(!entries.iter().any(|e| e.name == "escape"));
350    }
351
352    #[test]
353    fn normalize_git_change_path_handles_rename_like_strings() {
354        let p = PathBuf::from("old name.rs -> new name.rs");
355        assert_eq!(
356            normalize_git_change_path(&p).as_deref(),
357            Some("new name.rs")
358        );
359
360        let p = PathBuf::from("\"old.rs -> new.rs\"");
361        assert_eq!(normalize_git_change_path(&p).as_deref(), Some("new.rs"));
362    }
363}