use crate::okf::model::BuildOptions;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub rel_path: String,
pub abs_path: PathBuf,
pub size: u64,
pub mtime: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredAttachment {
pub rel_path: String,
pub abs_path: PathBuf,
pub size: u64,
pub mtime: Option<i64>,
}
#[derive(Debug, Clone, Default)]
pub struct WalkResult {
pub concepts: Vec<DiscoveredFile>,
pub index_files: HashMap<String, PathBuf>,
pub diverted: Vec<DiscoveredFile>,
pub attachments: Vec<DiscoveredAttachment>,
}
fn is_ignored_dir(name: &str) -> bool {
name.starts_with('.')
|| matches!(
name,
"node_modules" | "target" | "__pycache__" | "venv" | "env" | "site-packages"
)
}
fn matches_skip(rel: &str, name: &str, skip_dirs: &[&str]) -> bool {
skip_dirs.iter().any(|raw| {
let entry = raw.trim_matches('/');
if entry.is_empty() {
false
} else if entry.contains('/') {
rel == entry || rel.starts_with(&format!("{entry}/"))
} else {
name == entry
}
})
}
pub(crate) fn check_root(root: &Path) -> Result<(), String> {
if !root.exists() {
return Err(format!(
"OKF bundle path does not exist: {}",
root.display()
));
}
if !root.is_dir() {
return Err(format!(
"OKF bundle path is not a directory: {}",
root.display()
));
}
Ok(())
}
pub fn discover(root: &Path, opts: &BuildOptions) -> Result<WalkResult, String> {
let skip_dirs: Vec<&str> = opts
.skip_dirs
.iter()
.chain(opts.profile.skip_dirs.iter())
.map(String::as_str)
.collect();
let skip_dirs = skip_dirs.as_slice();
check_root(root)?;
let mut out = Vec::new();
let mut diverted = Vec::new();
let mut attachments = Vec::new();
let mut index_files: HashMap<String, PathBuf> = HashMap::new();
let walker = WalkDir::new(root).into_iter().filter_entry(|e| {
if e.depth() == 0 || !e.file_type().is_dir() {
return true;
}
let Some(name) = e.file_name().to_str() else {
return true;
};
if is_ignored_dir(name) {
return false;
}
if !skip_dirs.is_empty() {
let rel = e
.path()
.strip_prefix(root)
.ok()
.map(|r| {
r.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default();
if matches_skip(&rel, name, skip_dirs) {
return false;
}
}
true
});
for entry in walker.filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let name = match entry.file_name().to_str() {
Some(n) => n,
None => continue,
};
let rel = match entry.path().strip_prefix(root) {
Ok(r) => r,
Err(_) => continue,
};
let rel_path = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/");
if !name.ends_with(".md") {
if opts.profile.attachments && !name.starts_with('.') {
let meta = entry.metadata().ok();
attachments.push(DiscoveredAttachment {
rel_path,
abs_path: entry.path().to_path_buf(),
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
mtime: meta.as_ref().and_then(mtime_secs),
});
}
continue;
}
let meta = entry.metadata().ok();
if name == "log.md" && opts.profile.skip_log_files {
diverted.push(DiscoveredFile {
rel_path,
abs_path: entry.path().to_path_buf(),
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
mtime: meta.as_ref().and_then(mtime_secs),
});
continue;
}
if name == "index.md" && opts.profile.index_as_folder_metadata {
let dir = rel_path
.rfind('/')
.map(|i| rel_path[..i].to_string())
.unwrap_or_default();
index_files.insert(dir, entry.path().to_path_buf());
diverted.push(DiscoveredFile {
rel_path,
abs_path: entry.path().to_path_buf(),
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
mtime: meta.as_ref().and_then(mtime_secs),
});
continue;
}
out.push(DiscoveredFile {
rel_path,
abs_path: entry.path().to_path_buf(),
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
mtime: meta.as_ref().and_then(mtime_secs),
});
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
diverted.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
attachments.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(WalkResult {
concepts: out,
index_files,
diverted,
attachments,
})
}
pub(crate) fn mtime_secs(meta: &std::fs::Metadata) -> Option<i64> {
let modified = meta.modified().ok()?;
Some(match modified.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(e) => -(e.duration().as_secs() as i64),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::okf::model::Profile;
use std::fs;
use tempfile::tempdir;
fn bundle() -> tempfile::TempDir {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("notes")).unwrap();
for rel in ["notes/a.md", "notes/index.md", "notes/log.md"] {
fs::write(dir.path().join(rel), "body").unwrap();
}
dir
}
fn rel_paths(r: &WalkResult) -> Vec<&str> {
r.concepts.iter().map(|f| f.rel_path.as_str()).collect()
}
#[test]
fn default_profile_reserves_index_and_log() {
let dir = bundle();
let r = discover(dir.path(), &BuildOptions::default()).unwrap();
assert_eq!(rel_paths(&r), vec!["notes/a.md"]);
assert!(
r.index_files.contains_key("notes"),
"index.md → folder meta"
);
}
#[test]
fn only_the_attachment_profile_indexes_non_md_files() {
let dir = bundle();
fs::create_dir_all(dir.path().join("img")).unwrap();
fs::write(dir.path().join("img/diagram.png"), b"\x89PNG\r\n").unwrap();
fs::write(dir.path().join("notes/.DS_Store"), b"junk").unwrap();
fs::create_dir_all(dir.path().join(".obsidian")).unwrap();
fs::write(dir.path().join(".obsidian/workspace.json"), b"{}").unwrap();
assert!(
discover(dir.path(), &BuildOptions::default())
.unwrap()
.attachments
.is_empty(),
"an OKF sweep drops attachment references, so it stats nothing"
);
let opts = BuildOptions::for_dialect(crate::okf::model::Dialect::Obsidian);
let r = discover(dir.path(), &opts).unwrap();
let paths: Vec<&str> = r.attachments.iter().map(|a| a.rel_path.as_str()).collect();
assert_eq!(
paths,
vec!["img/diagram.png"],
"hidden files and pruned dot-directories are not vault content"
);
assert_eq!(r.attachments[0].size, 6);
assert!(
r.attachments[0].mtime.is_some_and(|m| m > 1_600_000_000),
"a freshly written file has an mtime well past 2020"
);
assert!(
rel_paths(&r).contains(&"notes/log.md"),
"the vault profile keeps `log.md` a note, not an attachment"
);
}
#[test]
fn profile_can_make_index_and_log_ordinary_concepts() {
let dir = bundle();
let opts = BuildOptions {
profile: Profile {
index_as_folder_metadata: false,
skip_log_files: false,
..Profile::default()
},
..BuildOptions::default()
};
let r = discover(dir.path(), &opts).unwrap();
assert_eq!(
rel_paths(&r),
vec!["notes/a.md", "notes/index.md", "notes/log.md"]
);
assert!(r.index_files.is_empty(), "no folder metadata was diverted");
}
}