use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FrnEntry {
pub parent_frn: u64,
pub name: String,
pub is_dir: bool,
}
#[derive(Debug, Default)]
pub(crate) struct FrnTable {
entries: HashMap<u64, FrnEntry>,
roots: HashMap<u64, PathBuf>,
}
impl FrnTable {
pub fn new() -> Self {
Self::default()
}
pub fn register_root(&mut self, frn: u64, path: PathBuf) {
self.roots.insert(frn, path);
}
pub fn upsert(&mut self, frn: u64, entry: FrnEntry) {
self.entries.insert(frn, entry);
}
#[cfg(test)]
pub fn get(&self, frn: u64) -> Option<&FrnEntry> {
self.entries.get(&frn)
}
pub fn remove(&mut self, frn: u64) -> Option<FrnEntry> {
self.entries.remove(&frn)
}
pub fn reconstruct_path(&self, frn: u64) -> Option<PathBuf> {
const MAX_DEPTH: usize = 512;
if let Some(root) = self.roots.get(&frn) {
return Some(root.clone());
}
let mut names: Vec<&str> = Vec::new();
let mut current = frn;
for _ in 0..MAX_DEPTH {
if let Some(root) = self.roots.get(¤t) {
let mut path = root.clone();
for name in names.iter().rev() {
path.push(name);
}
return Some(path);
}
let entry = self.entries.get(¤t)?;
names.push(entry.name.as_str());
current = entry.parent_frn;
}
None
}
pub fn in_scope(&self, frn: u64) -> bool {
self.reconstruct_path(frn).is_some()
}
pub fn retain_in_scope(&mut self) {
let keep: std::collections::HashSet<u64> = self
.entries
.keys()
.copied()
.filter(|&frn| self.in_scope(frn))
.collect();
self.entries.retain(|frn, _| keep.contains(frn));
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&u64, &FrnEntry)> {
self.entries.iter()
}
}
#[cfg(test)]
fn last_component(path: &std::path::Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn root_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\watch")
} else {
PathBuf::from("/watch")
}
}
#[test]
fn reconstructs_path_for_root_itself() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
assert_eq!(table.reconstruct_path(1), Some(root_path()));
}
#[test]
fn reconstructs_nested_path_through_parent_chain() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
2,
FrnEntry {
parent_frn: 1,
name: "sub".to_string(),
is_dir: true,
},
);
table.upsert(
3,
FrnEntry {
parent_frn: 2,
name: "a.md".to_string(),
is_dir: false,
},
);
assert_eq!(
table.reconstruct_path(3),
Some(root_path().join("sub").join("a.md"))
);
assert_eq!(
last_component(&root_path().join("sub").join("a.md")),
"a.md"
);
}
#[test]
fn missing_ancestor_breaks_the_chain_and_returns_none() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
3,
FrnEntry {
parent_frn: 2,
name: "orphan.md".to_string(),
is_dir: false,
},
);
assert_eq!(table.reconstruct_path(3), None);
assert!(!table.in_scope(3));
}
#[test]
fn frn_outside_any_registered_root_returns_none() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
99,
FrnEntry {
parent_frn: 42, name: "elsewhere.md".to_string(),
is_dir: false,
},
);
assert_eq!(table.reconstruct_path(99), None);
}
#[test]
fn upsert_overwrites_previous_entry_for_same_frn() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
2,
FrnEntry {
parent_frn: 1,
name: "old-name.md".to_string(),
is_dir: false,
},
);
assert_eq!(
table.reconstruct_path(2),
Some(root_path().join("old-name.md"))
);
table.upsert(
2,
FrnEntry {
parent_frn: 1,
name: "new-name.md".to_string(),
is_dir: false,
},
);
assert_eq!(
table.reconstruct_path(2),
Some(root_path().join("new-name.md"))
);
assert_eq!(table.entry_count(), 1);
}
#[test]
fn remove_drops_entry_and_breaks_descendants() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
2,
FrnEntry {
parent_frn: 1,
name: "sub".to_string(),
is_dir: true,
},
);
table.upsert(
3,
FrnEntry {
parent_frn: 2,
name: "a.md".to_string(),
is_dir: false,
},
);
table.remove(2);
assert_eq!(table.reconstruct_path(2), None);
assert_eq!(table.reconstruct_path(3), None);
}
#[test]
fn retain_in_scope_drops_entries_whose_chain_never_reaches_a_root() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
2,
FrnEntry {
parent_frn: 1,
name: "sub".to_string(),
is_dir: true,
},
);
table.upsert(
3,
FrnEntry {
parent_frn: 2,
name: "a.md".to_string(),
is_dir: false,
},
);
table.upsert(
100,
FrnEntry {
parent_frn: 999,
name: "unrelated.md".to_string(),
is_dir: false,
},
);
assert_eq!(table.entry_count(), 3);
table.retain_in_scope();
assert_eq!(table.entry_count(), 2);
assert_eq!(
table.reconstruct_path(3),
Some(root_path().join("sub").join("a.md"))
);
assert_eq!(table.reconstruct_path(100), None);
}
#[test]
fn circular_parent_chain_terminates_instead_of_hanging() {
let mut table = FrnTable::new();
table.register_root(1, root_path());
table.upsert(
2,
FrnEntry {
parent_frn: 3,
name: "a".to_string(),
is_dir: true,
},
);
table.upsert(
3,
FrnEntry {
parent_frn: 2,
name: "b".to_string(),
is_dir: true,
},
);
assert_eq!(table.reconstruct_path(2), None);
}
}