use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum LocatorError {
UnsupportedScheme { scheme: String },
InvalidUri { reason: String },
Io(std::io::Error),
NotFound,
Status { code: u16, body: String },
}
impl std::fmt::Display for LocatorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedScheme { scheme } => {
write!(f, "unsupported locator scheme: {scheme}")
}
Self::InvalidUri { reason } => write!(f, "invalid URI: {reason}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::NotFound => write!(f, "resource not found"),
Self::Status { code, body } => {
write!(f, "HTTP {code}: {body}")
}
}
}
}
impl std::error::Error for LocatorError {}
impl From<std::io::Error> for LocatorError {
fn from(e: std::io::Error) -> Self {
if e.kind() == std::io::ErrorKind::NotFound {
Self::NotFound
} else {
Self::Io(e)
}
}
}
pub trait Locator: Send + Sync {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, LocatorError>;
fn fetch_range(&self, uri: &str, offset: u64, length: u64) -> Result<Vec<u8>, LocatorError> {
let data = self.fetch(uri)?;
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let start = offset.min(total);
let end = start.saturating_add(length).min(total);
let start_us = usize::try_from(start).unwrap_or(usize::MAX);
let end_us = usize::try_from(end).unwrap_or(usize::MAX);
Ok(data[start_us..end_us].to_vec())
}
fn scheme(&self) -> &'static str;
}
#[derive(Debug, Clone)]
pub struct FileLocator {
base_dir: PathBuf,
}
impl FileLocator {
#[must_use]
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
#[must_use]
pub fn for_manifest(manifest_path: &Path) -> Self {
let base_dir = manifest_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Self { base_dir }
}
fn resolve(&self, uri: &str) -> Result<PathBuf, LocatorError> {
let path_str =
uri.strip_prefix("file:")
.ok_or_else(|| LocatorError::UnsupportedScheme {
scheme: scheme_of(uri).to_owned(),
})?;
if path_str.is_empty() {
return Err(LocatorError::InvalidUri {
reason: "file: URI has empty path".into(),
});
}
Ok(self.base_dir.join(path_str))
}
}
impl Locator for FileLocator {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, LocatorError> {
let path = self.resolve(uri)?;
if !path.exists() {
return Err(LocatorError::NotFound);
}
Ok(std::fs::read(&path)?)
}
fn scheme(&self) -> &'static str {
"file"
}
}
#[derive(Default)]
pub struct MultiLocator {
locators: Vec<Box<dyn Locator>>,
}
impl MultiLocator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with(mut self, locator: Box<dyn Locator>) -> Self {
self.locators.push(locator);
self
}
pub fn register(&mut self, locator: Box<dyn Locator>) -> &mut Self {
self.locators.push(locator);
self
}
}
impl Locator for MultiLocator {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, LocatorError> {
let scheme = scheme_of(uri);
for locator in &self.locators {
if locator.scheme() == scheme {
return locator.fetch(uri);
}
}
Err(LocatorError::UnsupportedScheme {
scheme: scheme.to_owned(),
})
}
fn scheme(&self) -> &'static str {
"multi"
}
}
fn scheme_of(uri: &str) -> &str {
match uri.find(':') {
Some(pos) => &uri[..pos],
None => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn file_locator_reads_local_file() {
let dir =
std::env::temp_dir().join(format!("limnifs-locator-trait-{}-1", std::process::id()));
std::fs::create_dir_all(&dir).expect("create dir");
let path = dir.join("data.bin");
let mut file = std::fs::File::create(&path).expect("create");
file.write_all(b"hello locator").expect("write");
let locator = FileLocator::new(&dir);
let data = locator.fetch("file:data.bin").expect("fetch");
assert_eq!(data, b"hello locator");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn file_locator_rejects_non_file_scheme() {
let locator = FileLocator::new("/tmp");
match locator.fetch("https://example.com/data.bin") {
Err(LocatorError::UnsupportedScheme { scheme }) => {
assert_eq!(scheme, "https");
}
other => panic!("expected UnsupportedScheme, got {other:?}"),
}
}
#[test]
fn file_locator_rejects_empty_path() {
let locator = FileLocator::new("/tmp");
match locator.fetch("file:") {
Err(LocatorError::InvalidUri { .. }) => {}
other => panic!("expected InvalidUri, got {other:?}"),
}
}
#[test]
fn file_locator_returns_not_found() {
let locator = FileLocator::new("/tmp");
match locator.fetch("file:nonexistent-12345.bin") {
Err(LocatorError::NotFound) => {}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn multi_locator_dispatches_by_scheme() {
let dir =
std::env::temp_dir().join(format!("limnifs-locator-trait-{}-2", std::process::id()));
std::fs::create_dir_all(&dir).expect("create dir");
let path = dir.join("slab-0.bin");
let mut file = std::fs::File::create(&path).expect("create");
file.write_all(b"slab data").expect("write");
let multi = MultiLocator::new().with(Box::new(FileLocator::new(&dir)));
let data = multi.fetch("file:slab-0.bin").expect("fetch");
assert_eq!(data, b"slab data");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn multi_locator_rejects_unregistered_scheme() {
let multi = MultiLocator::new();
match multi.fetch("s3://bucket/key") {
Err(LocatorError::UnsupportedScheme { scheme }) => {
assert_eq!(scheme, "s3");
}
other => panic!("expected UnsupportedScheme, got {other:?}"),
}
}
#[test]
fn scheme_of_extracts_prefix() {
assert_eq!(scheme_of("file:data.bin"), "file");
assert_eq!(scheme_of("https://example.com/x"), "https");
assert_eq!(scheme_of("s3://bucket/key"), "s3");
assert_eq!(scheme_of("no-scheme"), "");
}
#[test]
fn for_manifest_uses_parent_dir() {
let path = std::path::Path::new("/tmp/manifest.lim");
let locator = FileLocator::for_manifest(path);
assert_eq!(locator.base_dir, std::path::Path::new("/tmp"));
}
}