use std::cmp::Reverse;
use std::path::{Path, PathBuf};
use crate::{
Backend, Icon, Key, Modifiers, NamedKey, Palette, PaletteItem, Rect, StyledText, UiEvent,
WidgetId,
};
pub const PALETTE_CHROME_ROWS: usize = 4;
pub const DEFAULT_MAX_DEPTH: usize = 5;
pub const DEFAULT_IGNORE_DIRS: &[&str] = &["target", "node_modules", "__pycache__"];
#[derive(Debug, Clone, PartialEq)]
pub enum FolderPickerEvent {
Confirmed { path: PathBuf },
Cancelled,
Consumed,
Ignored,
}
pub struct FolderPickerController {
id: WidgetId,
root: PathBuf,
query: String,
all_entries: Vec<PathBuf>,
filtered: Vec<PathBuf>,
selected: usize,
scroll_top: usize,
show_hidden: bool,
extra_file_names: Vec<String>,
ignore_dirs: Vec<String>,
max_depth: usize,
}
impl FolderPickerController {
pub fn new(root: impl Into<PathBuf>, extra_file_names: Vec<String>, show_hidden: bool) -> Self {
let root = root.into();
let ignore_dirs: Vec<String> = DEFAULT_IGNORE_DIRS
.iter()
.map(|s| (*s).to_string())
.collect();
let max_depth = DEFAULT_MAX_DEPTH;
let all_entries = collect_dir_entries(
&root,
show_hidden,
&extra_file_names,
&ignore_dirs,
max_depth,
);
let filtered = all_entries.iter().take(50).cloned().collect();
Self {
id: WidgetId::new("folder_picker"),
root,
query: String::new(),
all_entries,
filtered,
selected: 0,
scroll_top: 0,
show_hidden,
extra_file_names,
ignore_dirs,
max_depth,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = WidgetId::new(id.into());
self
}
pub fn with_ignore_dirs(mut self, ignore_dirs: Vec<String>) -> Self {
self.ignore_dirs = ignore_dirs;
self.all_entries = collect_dir_entries(
&self.root,
self.show_hidden,
&self.extra_file_names,
&self.ignore_dirs,
self.max_depth,
);
self.refilter();
self
}
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self.all_entries = collect_dir_entries(
&self.root,
self.show_hidden,
&self.extra_file_names,
&self.ignore_dirs,
self.max_depth,
);
self.refilter();
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn query(&self) -> &str {
&self.query
}
pub fn filtered(&self) -> &[PathBuf] {
&self.filtered
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
pub fn selected_path(&self) -> Option<PathBuf> {
let rel = self.filtered.get(self.selected)?;
if rel.as_os_str() == ".." {
self.root.parent().map(|p| p.to_path_buf())
} else {
Some(self.root.join(rel))
}
}
pub fn navigate_to(&mut self, new_root: PathBuf) {
self.root = new_root;
self.query.clear();
self.all_entries = collect_dir_entries(
&self.root,
self.show_hidden,
&self.extra_file_names,
&self.ignore_dirs,
self.max_depth,
);
self.filtered = self.all_entries.iter().take(50).cloned().collect();
self.selected = 0;
self.scroll_top = 0;
}
pub fn navigate_up(&mut self) {
if let Some(parent) = self.root.parent() {
self.navigate_to(parent.to_path_buf());
}
}
pub fn push_char(&mut self, c: char) {
self.query.push(c);
self.refilter();
}
pub fn pop_char(&mut self) {
self.query.pop();
self.refilter();
}
pub fn refilter(&mut self) {
self.filtered = filter_dir_entries(&self.all_entries, &self.query);
self.selected = 0;
self.scroll_top = 0;
}
pub fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn move_down(&mut self) {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1).min(self.filtered.len() - 1);
}
}
pub fn sync_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected < self.scroll_top {
self.scroll_top = self.selected;
}
if self.selected >= self.scroll_top + visible_rows {
self.scroll_top = self.selected + 1 - visible_rows;
}
}
pub fn render(&self, rect: Rect, backend: &mut dyn Backend) {
let palette = self.build_palette(rect);
backend.draw_palette(rect, &palette);
}
pub fn handle(&mut self, event: &UiEvent, visible_rows: usize) -> FolderPickerEvent {
let result = match event {
UiEvent::KeyPressed { key, modifiers, .. } => self.handle_key(key, modifiers),
_ => FolderPickerEvent::Ignored,
};
if matches!(result, FolderPickerEvent::Consumed) {
self.sync_scroll(visible_rows);
}
result
}
fn handle_key(&mut self, key: &Key, modifiers: &Modifiers) -> FolderPickerEvent {
let ctrl = modifiers.ctrl;
match key {
Key::Named(NamedKey::Escape) => FolderPickerEvent::Cancelled,
Key::Named(NamedKey::Enter) => {
let is_dotdot = self
.filtered
.get(self.selected)
.map(|p| p.as_os_str() == "..")
.unwrap_or(false);
if is_dotdot {
self.navigate_up();
FolderPickerEvent::Consumed
} else if let Some(path) = self.selected_path() {
FolderPickerEvent::Confirmed { path }
} else {
FolderPickerEvent::Consumed
}
}
Key::Char('-') if !ctrl => {
self.navigate_up();
FolderPickerEvent::Consumed
}
Key::Named(NamedKey::Up) => {
self.move_up();
FolderPickerEvent::Consumed
}
Key::Char('k') if !ctrl => {
self.move_up();
FolderPickerEvent::Consumed
}
Key::Named(NamedKey::Down) => {
self.move_down();
FolderPickerEvent::Consumed
}
Key::Char('j') if !ctrl => {
self.move_down();
FolderPickerEvent::Consumed
}
Key::Named(NamedKey::Backspace) => {
self.pop_char();
FolderPickerEvent::Consumed
}
Key::Char(c) if !ctrl => {
self.push_char(*c);
FolderPickerEvent::Consumed
}
_ => FolderPickerEvent::Ignored,
}
}
fn build_palette(&self, rect: Rect) -> Palette {
let r = self.root.to_string_lossy();
let max = (rect.width as usize).saturating_sub(30).max(10);
let root_display = if r.len() > max {
let s: &str = r.as_ref();
let target_drop = s.len().saturating_sub(max);
let mut split_at = s.len();
for (idx, _) in s.char_indices() {
if idx >= target_drop {
split_at = idx;
break;
}
}
format!("…{}", &s[split_at..])
} else {
r.into_owned()
};
let title = format!("Open Folder {root_display}");
let folder_icon = Icon::new("\u{1F4C1}", "\u{1F4C1}"); let file_icon = Icon::new("\u{2699}", "\u{2699}");
let items: Vec<PaletteItem> = self
.filtered
.iter()
.map(|entry| {
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_extra_file = self.extra_file_names.iter().any(|e| e == name);
let icon = if is_extra_file {
Some(file_icon.clone())
} else {
Some(folder_icon.clone())
};
PaletteItem {
text: StyledText::plain(entry.to_string_lossy().to_string()),
detail: None,
icon,
match_positions: Vec::new(),
depth: 0,
expandable: false,
expanded: false,
}
})
.collect();
Palette {
id: self.id,
title,
query: self.query.clone(),
query_cursor: self.query.len(),
items,
selected_idx: self.selected,
scroll_offset: self.scroll_top,
total_count: self.all_entries.len(),
has_focus: true,
show_query: true,
create_label: None,
preview: None,
mode: crate::primitives::palette::PaletteMode::List,
}
}
}
fn collect_dir_entries(
root: &Path,
show_hidden: bool,
extra_file_names: &[String],
ignore_dirs: &[String],
max_depth: usize,
) -> Vec<PathBuf> {
let mut out = Vec::new();
if root.parent().is_some() {
out.push(PathBuf::from(".."));
}
out.push(PathBuf::from("."));
walk_dir_entries_recursive(
root,
root,
&mut out,
0,
show_hidden,
extra_file_names,
ignore_dirs,
max_depth,
);
out
}
#[allow(clippy::too_many_arguments)]
fn walk_dir_entries_recursive(
root: &Path,
dir: &Path,
out: &mut Vec<PathBuf>,
depth: usize,
show_hidden: bool,
extra_file_names: &[String],
ignore_dirs: &[String],
max_depth: usize,
) {
if depth > max_depth {
return;
}
let mut entries: Vec<_> = match std::fs::read_dir(dir) {
Ok(e) => e.flatten().collect(),
Err(_) => return,
};
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_owned(),
None => continue,
};
if name.starts_with('.') && !show_hidden {
if path.is_file() && extra_file_names.iter().any(|e| e == &name) {
if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_path_buf());
}
}
continue;
}
if ignore_dirs.iter().any(|d| d == &name) {
continue;
}
if path.is_dir() {
if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_path_buf());
}
walk_dir_entries_recursive(
root,
&path,
out,
depth + 1,
show_hidden,
extra_file_names,
ignore_dirs,
max_depth,
);
}
}
}
fn filter_dir_entries(all: &[PathBuf], query: &str) -> Vec<PathBuf> {
const CAP: usize = 50;
if query.is_empty() {
return all.iter().take(CAP).cloned().collect();
}
let q = query.to_lowercase();
let mut scored: Vec<(i32, &PathBuf)> = all
.iter()
.filter_map(|p| {
let display = p.to_string_lossy().to_lowercase();
dir_fuzzy_score(&display, &q).map(|s| (s, p))
})
.collect();
scored.sort_by_key(|&(s, _)| Reverse(s));
scored
.into_iter()
.take(CAP)
.map(|(_, p)| p.clone())
.collect()
}
fn dir_fuzzy_score(path: &str, query: &str) -> Option<i32> {
crate::text_util::fuzzy_score(path, query).map(|(score, _positions)| score)
}
#[cfg(test)]
mod tests {
use super::*;
const PICKER_ROOT_CHILDREN: usize = 12;
fn picker_root() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("create picker tempdir");
for i in 0..PICKER_ROOT_CHILDREN {
std::fs::create_dir(dir.path().join(format!("child{i:02}"))).expect("create child dir");
}
dir
}
#[test]
fn fuzzy_score_exact_match_scores_high() {
let s = dir_fuzzy_score("src/main.rs", "src/main.rs");
assert!(s.is_some());
assert!(s.unwrap() > 0);
}
#[test]
fn fuzzy_score_subsequence_matches() {
let s = dir_fuzzy_score("src/main", "sm");
assert!(s.is_some());
}
#[test]
fn fuzzy_score_non_subsequence_is_none() {
let s = dir_fuzzy_score("src/main", "xyz");
assert!(s.is_none());
}
#[test]
fn fuzzy_score_boundary_bonus() {
let at_boundary = dir_fuzzy_score("src/main", "m");
let mid_word = dir_fuzzy_score("abcmain", "m");
assert!(at_boundary.unwrap() > mid_word.unwrap());
}
#[test]
fn fuzzy_score_empty_query_none() {
let s = dir_fuzzy_score("anything", "");
assert!(s.is_some());
}
#[test]
fn filter_returns_all_on_empty_query() {
let paths: Vec<PathBuf> = (0..10).map(|i| PathBuf::from(format!("dir{i}"))).collect();
let result = filter_dir_entries(&paths, "");
assert_eq!(result.len(), 10);
}
#[test]
fn filter_caps_at_50() {
let paths: Vec<PathBuf> = (0..100).map(|i| PathBuf::from(format!("dir{i}"))).collect();
let result = filter_dir_entries(&paths, "");
assert_eq!(result.len(), 50);
}
#[test]
fn filter_excludes_non_matching() {
let paths = vec![
PathBuf::from("src/main"),
PathBuf::from("docs/readme"),
PathBuf::from("tests/unit"),
];
let result = filter_dir_entries(&paths, "src");
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("src/main"));
}
#[test]
fn filter_sorts_by_score() {
let paths = vec![PathBuf::from("usr/local"), PathBuf::from("src/main")];
let result = filter_dir_entries(&paths, "sr");
assert_eq!(result[0], PathBuf::from("src/main"));
}
#[test]
fn new_sets_dotdot_and_dot_entries() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp, vec![], false);
assert!(picker.all_entries.contains(&PathBuf::from("..")));
assert!(picker.all_entries.contains(&PathBuf::from(".")));
assert_eq!(
picker.filtered().len(),
PICKER_ROOT_CHILDREN + 2,
"isolated root must yield exactly \"..\", \".\" and one entry per child"
);
}
#[test]
fn push_and_pop_char_update_query() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
picker.push_char('s');
picker.push_char('r');
assert_eq!(picker.query(), "sr");
picker.pop_char();
assert_eq!(picker.query(), "s");
}
#[test]
fn move_up_clamps_at_zero() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
assert_eq!(picker.selected(), 0);
picker.move_up();
assert_eq!(picker.selected(), 0);
}
#[test]
fn move_down_advances_selection() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
assert!(picker.filtered().len() >= 2);
picker.move_down();
assert_eq!(picker.selected(), 1);
}
#[test]
fn move_down_clamps_at_last() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
let last = picker.filtered().len().saturating_sub(1);
for _ in 0..last + 5 {
picker.move_down();
}
assert_eq!(picker.selected(), last);
}
#[test]
fn sync_scroll_brings_selected_into_view() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
assert!(picker.filtered().len() >= 10);
picker.selected = 9;
picker.sync_scroll(5);
assert!(picker.scroll_top + 5 > picker.selected);
assert!(picker.scroll_top <= picker.selected);
}
#[test]
fn sync_scroll_scrolls_up_when_needed() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
assert!(picker.filtered().len() >= 5);
picker.scroll_top = 5;
picker.selected = 2; picker.sync_scroll(3);
assert_eq!(picker.scroll_top, 2);
}
#[test]
fn escape_event_returns_cancelled() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
let ev = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(picker.handle(&ev, 10), FolderPickerEvent::Cancelled);
}
#[test]
fn enter_on_dotdot_navigates_up() {
let parent = tempfile::tempdir().expect("create parent tempdir");
let sub = parent.path().join("nav_up_child");
std::fs::create_dir_all(&sub).expect("create child dir");
let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
picker.selected = 0;
assert_eq!(picker.filtered().first(), Some(&PathBuf::from("..")));
let ev = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let result = picker.handle(&ev, 10);
assert_eq!(result, FolderPickerEvent::Consumed);
assert_eq!(picker.root(), parent.path());
}
#[test]
fn enter_on_dot_returns_confirmed_with_root() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp.clone(), vec![], false);
let dot_idx = picker
.filtered()
.iter()
.position(|p| p.as_os_str() == ".")
.expect("'.' should always be in filtered");
picker.selected = dot_idx;
let ev = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let result = picker.handle(&ev, 10);
assert_eq!(result, FolderPickerEvent::Confirmed { path: tmp });
}
#[test]
fn up_down_key_moves_selection() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
assert!(picker.filtered().len() >= 2);
let ev_down = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Down),
modifiers: Modifiers::default(),
repeat: false,
};
picker.handle(&ev_down, 10);
assert_eq!(picker.selected(), 1);
let ev_up = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
modifiers: Modifiers::default(),
repeat: false,
};
picker.handle(&ev_up, 10);
assert_eq!(picker.selected(), 0);
}
#[test]
fn key_char_appends_to_query() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
let ev = UiEvent::KeyPressed {
key: Key::Char('s'),
modifiers: Modifiers::default(),
repeat: false,
};
picker.handle(&ev, 10);
assert_eq!(picker.query(), "s");
}
#[test]
fn char_typed_is_ignored() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
let ev = UiEvent::CharTyped('s');
let result = picker.handle(&ev, 10);
assert_eq!(result, FolderPickerEvent::Ignored);
assert_eq!(picker.query(), "");
}
#[test]
fn backspace_removes_last_char() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let mut picker = FolderPickerController::new(tmp, vec![], false);
picker.push_char('s');
picker.push_char('r');
let ev = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Backspace),
modifiers: Modifiers::default(),
repeat: false,
};
picker.handle(&ev, 10);
assert_eq!(picker.query(), "s");
}
#[test]
fn selected_path_resolves_dot_to_root() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp.clone(), vec![], false);
let dot_idx = picker
.filtered()
.iter()
.position(|p| p.as_os_str() == ".")
.unwrap();
let mut picker = picker;
picker.selected = dot_idx;
assert_eq!(picker.selected_path(), Some(tmp));
}
#[test]
fn dash_key_navigates_up() {
let parent = tempfile::tempdir().expect("create parent tempdir");
let sub = parent.path().join("dash_nav_child");
std::fs::create_dir_all(&sub).expect("create child dir");
let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
let ev = UiEvent::KeyPressed {
key: Key::Char('-'),
modifiers: Modifiers::default(),
repeat: false,
};
let result = picker.handle(&ev, 10);
assert_eq!(result, FolderPickerEvent::Consumed);
assert_eq!(picker.root(), parent.path());
}
#[test]
fn build_palette_id_is_folder_picker() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp, vec![], false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let palette = picker.build_palette(rect);
assert_eq!(palette.id.as_str(), "folder_picker");
}
#[test]
fn build_palette_title_contains_open_folder() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp, vec![], false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let palette = picker.build_palette(rect);
assert!(palette.title.starts_with("Open Folder"));
}
#[test]
fn build_palette_items_match_filtered() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp, vec![], false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let palette = picker.build_palette(rect);
assert_eq!(palette.items.len(), picker.filtered().len());
}
#[test]
fn extra_file_names_gets_file_icon() {
let root = tempfile::tempdir().expect("create marker tempdir");
let marker = root.path().join(".my-workspace");
std::fs::write(&marker, b"").expect("write marker file");
let picker = FolderPickerController::new(
root.path().to_path_buf(),
vec![".my-workspace".to_string()],
false,
);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let palette = picker.build_palette(rect);
let has_marker = palette.items.iter().any(|item| {
item.text
.spans
.first()
.map(|s| s.text.contains(".my-workspace"))
.unwrap_or(false)
});
assert!(has_marker, "marker file should appear in palette items");
}
#[test]
fn build_palette_handles_non_ascii_root_path() {
let long_japanese = PathBuf::from(format!("/a/{}", "ト".repeat(50)));
let picker = FolderPickerController::new(long_japanese, vec![], false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let palette = picker.build_palette(rect);
assert!(palette.title.starts_with("Open Folder "));
assert!(palette.title.contains('…'));
}
#[test]
fn build_palette_handles_accented_root_path() {
let path = PathBuf::from(
"/home/utilisateur/répertoire/sous-répertoire/encore-plus-profond/données",
);
let picker = FolderPickerController::new(path, vec![], false);
let rect = Rect::new(0.0, 0.0, 60.0, 24.0);
let palette = picker.build_palette(rect);
assert!(palette.title.starts_with("Open Folder "));
}
#[test]
fn fuzzy_score_treats_backslash_as_boundary() {
let posix = dir_fuzzy_score("src/main", "m");
let windows = dir_fuzzy_score("src\\main", "m");
let mid = dir_fuzzy_score("abcmain", "m");
assert!(posix.unwrap() > mid.unwrap());
assert!(windows.unwrap() > mid.unwrap());
assert_eq!(posix.unwrap(), windows.unwrap());
}
#[test]
fn with_id_overrides_widget_id() {
let dir = picker_root();
let tmp = dir.path().to_path_buf();
let picker = FolderPickerController::new(tmp, vec![], false).with_id("picker_b");
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
assert_eq!(picker.build_palette(rect).id.as_str(), "picker_b");
}
#[test]
fn with_ignore_dirs_replaces_default_list() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("target")).unwrap();
std::fs::create_dir_all(root.path().join("keep_me")).unwrap();
let picker = FolderPickerController::new(root.path().to_path_buf(), vec![], false)
.with_ignore_dirs(vec![]);
let has_target = picker
.all_entries
.iter()
.any(|p| p == &PathBuf::from("target"));
let has_keep = picker
.all_entries
.iter()
.any(|p| p == &PathBuf::from("keep_me"));
assert!(
has_target,
"target should be present after with_ignore_dirs(empty)"
);
assert!(has_keep);
}
#[test]
fn with_max_depth_limits_recursion() {
let root = tempfile::tempdir().expect("tempdir");
let d3 = root.path().join("d1").join("d2").join("d3");
std::fs::create_dir_all(&d3).unwrap();
let picker =
FolderPickerController::new(root.path().to_path_buf(), vec![], false).with_max_depth(0);
let has_d1 = picker.all_entries.iter().any(|p| p.ends_with("d1"));
let has_d2 = picker.all_entries.iter().any(|p| p.ends_with("d2"));
assert!(has_d1);
assert!(!has_d2, "depth=0 should not surface nested subdirs");
}
}