use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::Graph;
use crate::fs::ReadStorage;
use crate::index::IdIndex;
use crate::link;
const SIDECAR_EXTENSIONS: &[&str] = &["yaml", "yml", "json", "toml", "fig", "figl"];
pub fn sidecar_candidates(payload: &Path) -> impl Iterator<Item = PathBuf> + '_ {
let name = payload
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
SIDECAR_EXTENSIONS
.iter()
.map(move |ext| payload.with_file_name(format!("{name}.{ext}")))
}
#[derive(Debug, Default, Clone)]
pub struct ShadowProbe {
sidecars: HashMap<PathBuf, Vec<PathBuf>>,
}
impl ShadowProbe {
pub fn over<'a>(listing: impl IntoIterator<Item = &'a PathBuf>) -> Self {
let mut ranked: HashMap<PathBuf, Vec<(usize, PathBuf)>> = HashMap::new();
for entry in listing {
let Some(rank) = entry
.extension()
.and_then(|e| e.to_str())
.and_then(|ext| SIDECAR_EXTENSIONS.iter().position(|known| *known == ext))
else {
continue;
};
ranked
.entry(entry.with_extension(""))
.or_default()
.push((rank, entry.clone()));
}
let sidecars = ranked
.into_iter()
.map(|(payload, mut found)| {
found.sort_by_key(|(rank, _)| *rank);
(payload, found.into_iter().map(|(_, path)| path).collect())
})
.collect();
Self { sidecars }
}
fn sidecars_for(&self, payload: &Path) -> &[PathBuf] {
self.sidecars.get(payload).map_or(&[], Vec::as_slice)
}
}
impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
pub async fn sidecar_claims(&self, candidate: &Path, payload: &Path) -> bool {
let Ok((_, doc)) = self.load(candidate).await else {
return false;
};
let Some(content) = doc.content_attr() else {
return false;
};
let dir = candidate.parent().unwrap_or(Path::new(""));
doc.is_attachment() && link::normalize(dir.join(content)) == payload
}
pub async fn is_shadowed_payload(&self, path: &Path, probe: &ShadowProbe) -> bool {
for candidate in probe.sidecars_for(path) {
if self.sidecar_claims(candidate, path).await {
return true;
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(names: &[&str]) -> Vec<PathBuf> {
names.iter().map(PathBuf::from).collect()
}
#[test]
fn the_probe_answers_what_probing_every_candidate_answered() {
let listing = paths(&[
"photo.jpg",
"photo.jpg.yaml",
"notes/scan.pdf",
"notes/scan.pdf.json",
"notes/a.md",
"loose.toml",
]);
let probe = ShadowProbe::over(listing.iter());
for path in &listing {
let by_candidate: Vec<PathBuf> = sidecar_candidates(path)
.filter(|c| listing.contains(c))
.collect();
assert_eq!(
probe.sidecars_for(path),
by_candidate.as_slice(),
"{}",
path.display()
);
}
}
#[test]
fn several_sidecars_come_back_in_preference_order() {
let listing = paths(&["photo.jpg.figl", "photo.jpg.yaml", "photo.jpg.json"]);
let probe = ShadowProbe::over(listing.iter());
assert_eq!(
probe.sidecars_for(Path::new("photo.jpg")),
paths(&["photo.jpg.yaml", "photo.jpg.json", "photo.jpg.figl"]).as_slice()
);
}
#[test]
fn a_listing_with_no_sidecars_claims_nothing() {
let listing = paths(&["index.md", "a.md", "b.md"]);
let probe = ShadowProbe::over(listing.iter());
assert!(probe.sidecars.is_empty());
assert!(probe.sidecars_for(Path::new("a.md")).is_empty());
}
}