This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/dirlist.rs
T
Miguel Palhas a575b8bef1 feat(maestro): refuse a kill when the worktree holds unsaved work (#79)
The guard moves off the worktree and onto the kill. DELETE
/api/sessions/:id now runs the shared unsaved_work probe before tearing
anything down and answers 409 kill_guarded with the breakdown on the
wire, so a client can say "3 uncommitted files, 2 unpushed commits".
The session keeps running and the tree is untouched.

WorktreeManager::remove is unconditional now — git itself still refuses
a dirty tree as a last line of defense.
2026-08-24 11:03:43 +01:00

201 lines
7.4 KiB
Rust

//! Directory listing for the spawn-form working-directory autocomplete (NAP-329).
//!
//! A daemon answers `ListDir { path }` by listing the **immediate
//! subdirectories** of one directory on its own host. The web client lists one
//! directory at a time and filters the trailing path segment locally, so this
//! never walks recursively and never returns files.
//!
//! Path expansion is done against the daemon's own `$HOME` so a client can send
//! an abbreviated path (empty, `~`, or relative) and get a sensible result:
//!
//! - empty → home dir
//! - `~` / `~/sub` → home / home + `sub`
//! - relative `a/b` → home + `a/b`
//! - absolute `/a/b` → as-is
//!
//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler
//! just wraps its `Result` in an `RpcResult` for the wire.
use std::path::{Path, PathBuf};
use maestro_protocol::{DirListing, ErrorInfo};
/// Maximum number of entries returned in a single listing. A directory with
/// more subdirectories than this is reported `truncated`; the client narrows
/// its query by typing more of the name. Bounds the wire payload regardless of
/// how large a directory is.
pub const MAX_ENTRIES: usize = 1000;
/// List the immediate subdirectories of `requested`, expanding it against
/// `home_dir` first. Returns directory **names** only (not full paths), sorted
/// case-insensitively. Hidden directories (dotfiles) are excluded.
pub fn list_dir(home_dir: &str, requested: &str) -> Result<DirListing, ErrorInfo> {
let dir = resolve(home_dir, requested);
let read = std::fs::read_dir(&dir).map_err(|e| io_error(&dir, &e))?;
let mut entries: Vec<String> = Vec::new();
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
// Skip hidden directories — the spawn form rarely targets dotfile dirs
// and they only clutter the completion list.
if name.starts_with('.') {
continue;
}
if is_dir(&entry) {
entries.push(name);
}
}
// Case-insensitive sort so `Projects` and `projects` interleave the way a
// user scanning the list expects.
entries.sort_by_key(|s| s.to_lowercase());
let truncated = entries.len() > MAX_ENTRIES;
entries.truncate(MAX_ENTRIES);
Ok(DirListing {
path: dir,
entries,
truncated,
})
}
/// Expand an abbreviated client path against the daemon's home dir.
///
/// Shared with the gitroot probe (#26) so a `~`/relative/empty path the client
/// sends is resolved against the daemon's `$HOME` the same way before walking
/// up for the enclosing repo.
pub fn resolve(home_dir: &str, requested: &str) -> PathBuf {
let home = PathBuf::from(home_dir);
let trimmed = requested.trim();
if trimmed.is_empty() {
return home;
}
if trimmed == "~" {
return home;
}
if let Some(rest) = trimmed.strip_prefix("~/") {
return home.join(rest);
}
let p = PathBuf::from(trimmed);
if p.is_absolute() {
p
} else {
home.join(p)
}
}
/// Whether a `DirEntry` denotes a directory, following symlinks so that a
/// symlinked project dir still completes (shells do the same).
fn is_dir(entry: &std::fs::DirEntry) -> bool {
match entry.file_type() {
Ok(ft) if ft.is_dir() => true,
Ok(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
.map(|m| m.is_dir())
.unwrap_or(false),
_ => false,
}
}
/// Map a `read_dir` failure to a stable [`ErrorInfo`] code the client can react
/// to. `NotADirectory` isn't a stable `ErrorKind` across platforms, so we probe
/// with a `metadata` call to distinguish "file, not dir" from "doesn't exist".
fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
use std::io::ErrorKind;
let (code, message) = match e.kind() {
ErrorKind::NotFound => ("not_found", "no such directory"),
ErrorKind::PermissionDenied => ("permission_denied", "permission denied"),
_ if dir.is_file() => ("not_a_directory", "path is a file, not a directory"),
_ => ("io_error", "could not read directory"),
};
ErrorInfo {
code: code.into(),
message: format!("{message}: {}", dir.display()),
unsaved_work: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Build a temp home with a few subdirs (one hidden), a file, and a symlink
/// to a dir. Returns the temp root path (its `Drop` cleans up).
fn fixture() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
fs::create_dir(home.join("projects")).unwrap();
fs::create_dir(home.join("Downloads")).unwrap();
fs::create_dir(home.join(".config")).unwrap();
fs::write(home.join("notes.txt"), b"x").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(home.join("projects"), home.join("link-to-projects")).unwrap();
tmp
}
#[test]
fn lists_visible_dirs_only_sorted_case_insensitively() {
let tmp = fixture();
let home = tmp.path().to_string_lossy();
let listing = list_dir(&home, "").unwrap();
// Files and hidden dirs are excluded; visible dirs (incl. a symlinked
// dir) are present.
assert!(!listing.entries.contains(&".config".to_string()));
assert!(listing.entries.contains(&"projects".to_string()));
assert!(listing.entries.contains(&"Downloads".to_string()));
assert!(!listing.entries.contains(&"notes.txt".to_string()));
#[cfg(unix)]
assert!(listing.entries.contains(&"link-to-projects".to_string()));
// Case-insensitive order: .config, Downloads, link-to-projects, projects
let lower: Vec<String> = listing.entries.iter().map(|s| s.to_lowercase()).collect();
let mut sorted = lower.clone();
sorted.sort();
assert_eq!(lower, sorted);
assert!(!listing.truncated);
assert_eq!(listing.path, tmp.path());
}
#[test]
fn empty_and_tilde_resolve_to_home() {
let tmp = fixture();
let home = tmp.path().to_string_lossy();
assert_eq!(list_dir(&home, "").unwrap().path, tmp.path());
assert_eq!(list_dir(&home, "~").unwrap().path, tmp.path());
}
#[test]
fn relative_and_tilde_slash_join_onto_home() {
let tmp = fixture();
let home = tmp.path().to_string_lossy();
let rel = list_dir(&home, "projects").unwrap();
assert_eq!(rel.path, tmp.path().join("projects"));
let tilde = list_dir(&home, "~/projects").unwrap();
assert_eq!(tilde.path, tmp.path().join("projects"));
}
#[test]
fn absolute_path_is_listed_as_is() {
let tmp = fixture();
let abs = tmp.path().join("projects");
// home is irrelevant for an absolute path.
let listing = list_dir("/nonexistent-home", &abs.to_string_lossy()).unwrap();
assert_eq!(listing.path, abs);
assert!(listing.entries.is_empty());
}
#[test]
fn missing_dir_is_not_found() {
let tmp = fixture();
let err = list_dir(&tmp.path().to_string_lossy(), "does-not-exist").unwrap_err();
assert_eq!(err.code, "not_found");
}
#[test]
fn file_target_is_not_a_directory() {
let tmp = fixture();
let err = list_dir(&tmp.path().to_string_lossy(), "notes.txt").unwrap_err();
assert_eq!(err.code, "not_a_directory");
}
}