use super::plan::ImportPlan;
use cyberbrain_core::{Error, Result, Slash};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Found {
pub rel: String,
pub abs: PathBuf,
}
pub fn list_files(root: &Path) -> Result<Vec<Found>> {
let mut out = Vec::new();
walk(root, root, &mut out)?;
out.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<Found>) -> Result<()> {
let io = |source: std::io::Error| Error::Io {
path: dir.to_path_buf(),
source,
};
for entry in std::fs::read_dir(dir).map_err(io)? {
let entry = entry.map_err(io)?;
let path = entry.path();
let ft = entry.file_type().map_err(io)?;
if ft.is_dir() {
walk(root, &path, out)?;
} else {
let rel = path
.strip_prefix(root)
.map_err(|_| {
Error::Config(format!("{} is not under {}", Slash(&path), Slash(root)))
})?
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
out.push(Found { rel, abs: path });
}
}
Ok(())
}
pub fn count_files_independently(root: &Path) -> Result<usize> {
fn go(dir: &Path) -> std::io::Result<usize> {
let mut n = 0;
for e in std::fs::read_dir(dir)? {
let e = e?;
if e.file_type()?.is_dir() {
n += go(&e.path())?;
} else {
n += 1;
}
}
Ok(n)
}
go(root).map_err(|e| Error::Io {
path: root.to_path_buf(),
source: e,
})
}
pub fn glob_match(pattern: &str, rel: &str) -> bool {
let pattern = pattern.trim_end_matches('/');
if pattern.contains('/') {
segments_match(
&pattern.split('/').collect::<Vec<_>>(),
&rel.split('/').collect::<Vec<_>>(),
)
} else {
let name = rel.rsplit('/').next().unwrap_or(rel);
segment_match(pattern, name)
}
}
fn segments_match(pat: &[&str], path: &[&str]) -> bool {
match pat.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => {
(0..=path.len()).any(|k| segments_match(rest, &path[k..]))
}
Some((first, rest)) => match path.split_first() {
Some((seg, path_rest)) => segment_match(first, seg) && segments_match(rest, path_rest),
None => false,
},
}
}
fn segment_match(pat: &str, s: &str) -> bool {
let p: Vec<char> = pat.chars().collect();
let t: Vec<char> = s.chars().collect();
fn go(p: &[char], t: &[char]) -> bool {
match p.split_first() {
None => t.is_empty(),
Some(('*', rest)) => (0..=t.len()).any(|k| go(rest, &t[k..])),
Some(('?', rest)) => !t.is_empty() && go(rest, &t[1..]),
Some((c, rest)) => t.first() == Some(c) && go(rest, &t[1..]),
}
}
go(&p, &t)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
Skip(usize),
Group(usize),
Unmapped,
}
pub fn classify(plan: &ImportPlan, rel: &str) -> Class {
for (i, s) in plan.skip.iter().enumerate() {
if s.paths.iter().any(|p| glob_match(p, rel)) {
return Class::Skip(i);
}
}
for (i, g) in plan.groups.iter().enumerate() {
if g.paths.iter().any(|p| glob_match(p, rel)) {
return Class::Group(i);
}
}
Class::Unmapped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn globs() {
assert!(glob_match("*.md", "a.md"));
assert!(glob_match("*.md", "deep/er/a.md"));
assert!(!glob_match("*.md", "a.mdx"));
assert!(glob_match("PREREG_*.md", "PREREG_va_2026-08-25.md"));
assert!(!glob_match("PREREG_*.md", "prereg_x.md"));
assert!(glob_match("*.bak-*", "STATUS.md.bak-20260903-2025"));
assert!(glob_match("backups/**", "backups/x"));
assert!(glob_match("backups/**", "backups/a/b/c.md"));
assert!(!glob_match("backups/**", "not/backups/x"));
assert!(glob_match("proposals/*.md", "proposals/x.md"));
assert!(!glob_match("proposals/*.md", "proposals/sub/x.md"));
assert!(glob_match("**/x.md", "x.md"));
assert!(glob_match("**/x.md", "a/b/x.md"));
assert!(glob_match("a?c", "abc"));
assert!(!glob_match("a?c", "ac"));
}
#[test]
fn both_counts_agree_and_symlinked_dirs_are_not_entered() {
let dir = tempfile::tempdir().unwrap();
let r = dir.path();
std::fs::create_dir_all(r.join("a/b")).unwrap();
std::fs::write(r.join("a/b/one.md"), "x").unwrap();
std::fs::write(r.join("two.md"), "x").unwrap();
std::fs::write(r.join(".hidden"), "x").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(r.join("a"), r.join("link-to-a")).unwrap();
let listed = list_files(r).unwrap();
let rels: Vec<&str> = listed.iter().map(|f| f.rel.as_str()).collect();
#[cfg(unix)]
assert_eq!(rels, [".hidden", "a/b/one.md", "link-to-a", "two.md"]);
#[cfg(not(unix))]
assert_eq!(rels, [".hidden", "a/b/one.md", "two.md"]);
assert_eq!(count_files_independently(r).unwrap(), listed.len());
}
}