pinakes-core: update remaining modules and tests

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
This commit is contained in:
raf 2026-03-08 00:42:29 +03:00
commit 3d9f8933d2
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
44 changed files with 1207 additions and 578 deletions

View file

@ -30,13 +30,18 @@ pub struct CacheStats {
} }
impl CacheStats { impl CacheStats {
#[must_use]
pub fn hit_rate(&self) -> f64 { pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses; let total = self.hits + self.misses;
if total == 0 { // Compute ratio using integer arithmetic: hits * 10000 / total gives basis
0.0 // points (0..=10000), then scale back to [0.0, 1.0]. Returns 0.0 if total
} else { // is zero.
self.hits as f64 / total as f64 let basis_points = self
} .hits
.saturating_mul(10_000)
.checked_div(total)
.unwrap_or(0);
f64::from(u32::try_from(basis_points).unwrap_or(u32::MAX)) / 10_000.0
} }
} }
@ -88,6 +93,7 @@ where
V: Clone + Send + Sync + 'static, V: Clone + Send + Sync + 'static,
{ {
/// Create a new cache with the specified TTL and maximum capacity. /// Create a new cache with the specified TTL and maximum capacity.
#[must_use]
pub fn new(ttl: Duration, max_capacity: u64) -> Self { pub fn new(ttl: Duration, max_capacity: u64) -> Self {
let inner = MokaCache::builder() let inner = MokaCache::builder()
.time_to_live(ttl) .time_to_live(ttl)
@ -101,6 +107,7 @@ where
} }
/// Create a new cache with TTL, max capacity, and time-to-idle. /// Create a new cache with TTL, max capacity, and time-to-idle.
#[must_use]
pub fn new_with_idle( pub fn new_with_idle(
ttl: Duration, ttl: Duration,
tti: Duration, tti: Duration,
@ -120,16 +127,16 @@ where
/// Get a value from the cache. /// Get a value from the cache.
pub async fn get(&self, key: &K) -> Option<V> { pub async fn get(&self, key: &K) -> Option<V> {
match self.inner.get(key).await { self.inner.get(key).await.map_or_else(
Some(value) => { || {
self.metrics.record_hit();
Some(value)
},
None => {
self.metrics.record_miss(); self.metrics.record_miss();
None None
}, },
} |value| {
self.metrics.record_hit();
Some(value)
},
)
} }
/// Insert a value into the cache. /// Insert a value into the cache.
@ -150,11 +157,13 @@ where
} }
/// Get the current number of entries in the cache. /// Get the current number of entries in the cache.
#[must_use]
pub fn entry_count(&self) -> u64 { pub fn entry_count(&self) -> u64 {
self.inner.entry_count() self.inner.entry_count()
} }
/// Get cache statistics. /// Get cache statistics.
#[must_use]
pub fn stats(&self) -> CacheStats { pub fn stats(&self) -> CacheStats {
let (hits, misses) = self.metrics.stats(); let (hits, misses) = self.metrics.stats();
CacheStats { CacheStats {
@ -168,11 +177,12 @@ where
/// Specialized cache for search query results. /// Specialized cache for search query results.
pub struct QueryCache { pub struct QueryCache {
/// Cache keyed by (query_hash, offset, limit) /// Cache keyed by (`query_hash`, offset, limit)
inner: Cache<String, String>, inner: Cache<String, String>,
} }
impl QueryCache { impl QueryCache {
#[must_use]
pub fn new(ttl: Duration, max_capacity: u64) -> Self { pub fn new(ttl: Duration, max_capacity: u64) -> Self {
Self { Self {
inner: Cache::new(ttl, max_capacity), inner: Cache::new(ttl, max_capacity),
@ -224,6 +234,7 @@ impl QueryCache {
self.inner.invalidate_all().await; self.inner.invalidate_all().await;
} }
#[must_use]
pub fn stats(&self) -> CacheStats { pub fn stats(&self) -> CacheStats {
self.inner.stats() self.inner.stats()
} }
@ -236,6 +247,7 @@ pub struct MetadataCache {
} }
impl MetadataCache { impl MetadataCache {
#[must_use]
pub fn new(ttl: Duration, max_capacity: u64) -> Self { pub fn new(ttl: Duration, max_capacity: u64) -> Self {
Self { Self {
inner: Cache::new(ttl, max_capacity), inner: Cache::new(ttl, max_capacity),
@ -257,6 +269,7 @@ impl MetadataCache {
self.inner.invalidate(&content_hash.to_string()).await; self.inner.invalidate(&content_hash.to_string()).await;
} }
#[must_use]
pub fn stats(&self) -> CacheStats { pub fn stats(&self) -> CacheStats {
self.inner.stats() self.inner.stats()
} }
@ -268,6 +281,7 @@ pub struct MediaCache {
} }
impl MediaCache { impl MediaCache {
#[must_use]
pub fn new(ttl: Duration, max_capacity: u64) -> Self { pub fn new(ttl: Duration, max_capacity: u64) -> Self {
Self { Self {
inner: Cache::new(ttl, max_capacity), inner: Cache::new(ttl, max_capacity),
@ -290,6 +304,7 @@ impl MediaCache {
self.inner.invalidate_all().await; self.inner.invalidate_all().await;
} }
#[must_use]
pub fn stats(&self) -> CacheStats { pub fn stats(&self) -> CacheStats {
self.inner.stats() self.inner.stats()
} }
@ -348,6 +363,7 @@ pub struct CacheLayer {
impl CacheLayer { impl CacheLayer {
/// Create a new cache layer with the specified TTL (using defaults for other /// Create a new cache layer with the specified TTL (using defaults for other
/// settings). /// settings).
#[must_use]
pub fn new(ttl_secs: u64) -> Self { pub fn new(ttl_secs: u64) -> Self {
let config = CacheConfig { let config = CacheConfig {
response_ttl_secs: ttl_secs, response_ttl_secs: ttl_secs,
@ -357,6 +373,7 @@ impl CacheLayer {
} }
/// Create a new cache layer with full configuration. /// Create a new cache layer with full configuration.
#[must_use]
pub fn with_config(config: CacheConfig) -> Self { pub fn with_config(config: CacheConfig) -> Self {
Self { Self {
responses: Cache::new( responses: Cache::new(
@ -401,6 +418,7 @@ impl CacheLayer {
} }
/// Get aggregated statistics for all caches. /// Get aggregated statistics for all caches.
#[must_use]
pub fn stats(&self) -> CacheLayerStats { pub fn stats(&self) -> CacheLayerStats {
CacheLayerStats { CacheLayerStats {
responses: self.responses.stats(), responses: self.responses.stats(),
@ -411,7 +429,8 @@ impl CacheLayer {
} }
/// Get the current configuration. /// Get the current configuration.
pub fn config(&self) -> &CacheConfig { #[must_use]
pub const fn config(&self) -> &CacheConfig {
&self.config &self.config
} }
} }
@ -427,6 +446,7 @@ pub struct CacheLayerStats {
impl CacheLayerStats { impl CacheLayerStats {
/// Get the overall hit rate across all caches. /// Get the overall hit rate across all caches.
#[must_use]
pub fn overall_hit_rate(&self) -> f64 { pub fn overall_hit_rate(&self) -> f64 {
let total_hits = self.responses.hits let total_hits = self.responses.hits
+ self.queries.hits + self.queries.hits
@ -438,15 +458,16 @@ impl CacheLayerStats {
+ self.metadata.misses + self.metadata.misses
+ self.media.misses; + self.media.misses;
if total_requests == 0 { let basis_points = total_hits
0.0 .saturating_mul(10_000)
} else { .checked_div(total_requests)
total_hits as f64 / total_requests as f64 .unwrap_or(0);
} f64::from(u32::try_from(basis_points).unwrap_or(u32::MAX)) / 10_000.0
} }
/// Get the total number of entries across all caches. /// Get the total number of entries across all caches.
pub fn total_entries(&self) -> u64 { #[must_use]
pub const fn total_entries(&self) -> u64 {
self.responses.size self.responses.size
+ self.queries.size + self.queries.size
+ self.metadata.size + self.metadata.size
@ -460,7 +481,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_cache_basic_operations() { async fn test_cache_basic_operations() {
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100); let cache: Cache<String, String> = Cache::new(Duration::from_mins(1), 100);
// Insert and get // Insert and get
cache.insert("key1".to_string(), "value1".to_string()).await; cache.insert("key1".to_string(), "value1".to_string()).await;
@ -479,7 +500,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_cache_stats() { async fn test_cache_stats() {
let cache: Cache<String, String> = Cache::new(Duration::from_secs(60), 100); let cache: Cache<String, String> = Cache::new(Duration::from_mins(1), 100);
cache.insert("key1".to_string(), "value1".to_string()).await; cache.insert("key1".to_string(), "value1".to_string()).await;
let _ = cache.get(&"key1".to_string()).await; // hit let _ = cache.get(&"key1".to_string()).await; // hit
@ -493,7 +514,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_query_cache() { async fn test_query_cache() {
let cache = QueryCache::new(Duration::from_secs(60), 100); let cache = QueryCache::new(Duration::from_mins(1), 100);
cache cache
.insert("test query", 0, 10, Some("name"), "results".to_string()) .insert("test query", 0, 10, Some("name"), "results".to_string())

View file

@ -1,6 +1,17 @@
use uuid::Uuid; use uuid::Uuid;
use crate::{error::Result, model::*, storage::DynStorageBackend}; use crate::{
error::Result,
model::{
AuditAction,
Collection,
CollectionKind,
MediaId,
MediaItem,
Pagination,
},
storage::DynStorageBackend,
};
/// Creates a new collection. /// Creates a new collection.
/// ///
@ -15,6 +26,10 @@ use crate::{error::Result, model::*, storage::DynStorageBackend};
/// # Returns /// # Returns
/// ///
/// The created collection /// The created collection
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn create_collection( pub async fn create_collection(
storage: &DynStorageBackend, storage: &DynStorageBackend,
name: &str, name: &str,
@ -39,6 +54,10 @@ pub async fn create_collection(
/// # Returns /// # Returns
/// ///
/// `Ok(())` on success /// `Ok(())` on success
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn add_member( pub async fn add_member(
storage: &DynStorageBackend, storage: &DynStorageBackend,
collection_id: Uuid, collection_id: Uuid,
@ -68,6 +87,10 @@ pub async fn add_member(
/// # Returns /// # Returns
/// ///
/// `Ok(())` on success /// `Ok(())` on success
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn remove_member( pub async fn remove_member(
storage: &DynStorageBackend, storage: &DynStorageBackend,
collection_id: Uuid, collection_id: Uuid,
@ -98,6 +121,10 @@ pub async fn remove_member(
/// # Returns /// # Returns
/// ///
/// List of media items in the collection /// List of media items in the collection
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn get_members( pub async fn get_members(
storage: &DynStorageBackend, storage: &DynStorageBackend,
collection_id: Uuid, collection_id: Uuid,

View file

@ -13,13 +13,15 @@ use crate::{
model::MediaItem, model::MediaItem,
}; };
/// Book enricher that tries OpenLibrary first, then falls back to Google Books /// Book enricher that tries `OpenLibrary` first, then falls back to Google
/// Books
pub struct BookEnricher { pub struct BookEnricher {
openlibrary: OpenLibraryClient, openlibrary: OpenLibraryClient,
googlebooks: GoogleBooksClient, googlebooks: GoogleBooksClient,
} }
impl BookEnricher { impl BookEnricher {
#[must_use]
pub fn new(google_api_key: Option<String>) -> Self { pub fn new(google_api_key: Option<String>) -> Self {
Self { Self {
openlibrary: OpenLibraryClient::new(), openlibrary: OpenLibraryClient::new(),
@ -27,7 +29,11 @@ impl BookEnricher {
} }
} }
/// Try to enrich from OpenLibrary first /// Try to enrich from `OpenLibrary` first
///
/// # Errors
///
/// Returns an error if the metadata cannot be serialized.
pub async fn try_openlibrary( pub async fn try_openlibrary(
&self, &self,
isbn: &str, isbn: &str,
@ -35,7 +41,7 @@ impl BookEnricher {
match self.openlibrary.fetch_by_isbn(isbn).await { match self.openlibrary.fetch_by_isbn(isbn).await {
Ok(book) => { Ok(book) => {
let metadata_json = serde_json::to_string(&book).map_err(|e| { let metadata_json = serde_json::to_string(&book).map_err(|e| {
PinakesError::External(format!("Failed to serialize metadata: {}", e)) PinakesError::External(format!("Failed to serialize metadata: {e}"))
})?; })?;
Ok(Some(ExternalMetadata { Ok(Some(ExternalMetadata {
@ -53,6 +59,10 @@ impl BookEnricher {
} }
/// Try to enrich from Google Books /// Try to enrich from Google Books
///
/// # Errors
///
/// Returns an error if the metadata cannot be serialized.
pub async fn try_googlebooks( pub async fn try_googlebooks(
&self, &self,
isbn: &str, isbn: &str,
@ -61,7 +71,7 @@ impl BookEnricher {
Ok(books) if !books.is_empty() => { Ok(books) if !books.is_empty() => {
let book = &books[0]; let book = &books[0];
let metadata_json = serde_json::to_string(book).map_err(|e| { let metadata_json = serde_json::to_string(book).map_err(|e| {
PinakesError::External(format!("Failed to serialize metadata: {}", e)) PinakesError::External(format!("Failed to serialize metadata: {e}"))
})?; })?;
Ok(Some(ExternalMetadata { Ok(Some(ExternalMetadata {
@ -79,6 +89,10 @@ impl BookEnricher {
} }
/// Try to enrich by searching with title and author /// Try to enrich by searching with title and author
///
/// # Errors
///
/// Returns an error if the metadata cannot be serialized.
pub async fn enrich_by_search( pub async fn enrich_by_search(
&self, &self,
title: &str, title: &str,
@ -89,7 +103,7 @@ impl BookEnricher {
&& let Some(result) = results.first() && let Some(result) = results.first()
{ {
let metadata_json = serde_json::to_string(result).map_err(|e| { let metadata_json = serde_json::to_string(result).map_err(|e| {
PinakesError::External(format!("Failed to serialize metadata: {}", e)) PinakesError::External(format!("Failed to serialize metadata: {e}"))
})?; })?;
return Ok(Some(ExternalMetadata { return Ok(Some(ExternalMetadata {
@ -108,7 +122,7 @@ impl BookEnricher {
&& let Some(book) = results.first() && let Some(book) = results.first()
{ {
let metadata_json = serde_json::to_string(book).map_err(|e| { let metadata_json = serde_json::to_string(book).map_err(|e| {
PinakesError::External(format!("Failed to serialize metadata: {}", e)) PinakesError::External(format!("Failed to serialize metadata: {e}"))
})?; })?;
return Ok(Some(ExternalMetadata { return Ok(Some(ExternalMetadata {
@ -158,7 +172,8 @@ impl MetadataEnricher for BookEnricher {
} }
} }
/// Calculate confidence score for OpenLibrary metadata /// Calculate confidence score for `OpenLibrary` metadata
#[must_use]
pub fn calculate_openlibrary_confidence( pub fn calculate_openlibrary_confidence(
book: &super::openlibrary::OpenLibraryBook, book: &super::openlibrary::OpenLibraryBook,
) -> f64 { ) -> f64 {
@ -187,6 +202,7 @@ pub fn calculate_openlibrary_confidence(
} }
/// Calculate confidence score for Google Books metadata /// Calculate confidence score for Google Books metadata
#[must_use]
pub fn calculate_googlebooks_confidence( pub fn calculate_googlebooks_confidence(
info: &super::googlebooks::VolumeInfo, info: &super::googlebooks::VolumeInfo,
) -> f64 { ) -> f64 {

View file

@ -1,3 +1,5 @@
use std::fmt::Write as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::{PinakesError, Result}; use crate::error::{PinakesError, Result};
@ -9,30 +11,33 @@ pub struct GoogleBooksClient {
} }
impl GoogleBooksClient { impl GoogleBooksClient {
/// Create a new `GoogleBooksClient`.
#[must_use]
pub fn new(api_key: Option<String>) -> Self { pub fn new(api_key: Option<String>) -> Self {
Self { let client = reqwest::Client::builder()
client: reqwest::Client::builder() .user_agent("Pinakes/1.0")
.user_agent("Pinakes/1.0") .timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(10)) .build()
.build() .unwrap_or_else(|_| reqwest::Client::new());
.expect("Failed to build HTTP client"), Self { client, api_key }
api_key,
}
} }
/// Fetch book metadata by ISBN /// Fetch book metadata by ISBN
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// parsed.
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<Vec<GoogleBook>> { pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<Vec<GoogleBook>> {
let mut url = format!( let mut url =
"https://www.googleapis.com/books/v1/volumes?q=isbn:{}", format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}");
isbn
);
if let Some(ref key) = self.api_key { if let Some(ref key) = self.api_key {
url.push_str(&format!("&key={}", key)); let _ = write!(url, "&key={key}");
} }
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("Google Books request failed: {}", e)) PinakesError::External(format!("Google Books request failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -44,8 +49,7 @@ impl GoogleBooksClient {
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
PinakesError::External(format!( PinakesError::External(format!(
"Failed to parse Google Books response: {}", "Failed to parse Google Books response: {e}"
e
)) ))
})?; })?;
@ -53,6 +57,11 @@ impl GoogleBooksClient {
} }
/// Search for books by title and author /// Search for books by title and author
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// parsed.
pub async fn search( pub async fn search(
&self, &self,
title: &str, title: &str,
@ -61,20 +70,19 @@ impl GoogleBooksClient {
let mut query = format!("intitle:{}", urlencoding::encode(title)); let mut query = format!("intitle:{}", urlencoding::encode(title));
if let Some(author) = author { if let Some(author) = author {
query.push_str(&format!("+inauthor:{}", urlencoding::encode(author))); let _ = write!(query, "+inauthor:{}", urlencoding::encode(author));
} }
let mut url = format!( let mut url = format!(
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5", "https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5"
query
); );
if let Some(ref key) = self.api_key { if let Some(ref key) = self.api_key {
url.push_str(&format!("&key={}", key)); let _ = write!(url, "&key={key}");
} }
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("Google Books search failed: {}", e)) PinakesError::External(format!("Google Books search failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -85,13 +93,18 @@ impl GoogleBooksClient {
} }
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
PinakesError::External(format!("Failed to parse search results: {}", e)) PinakesError::External(format!("Failed to parse search results: {e}"))
})?; })?;
Ok(volumes.items) Ok(volumes.items)
} }
/// Download cover image from Google Books /// Download cover image from Google Books
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// read.
pub async fn fetch_cover(&self, image_link: &str) -> Result<Vec<u8>> { pub async fn fetch_cover(&self, image_link: &str) -> Result<Vec<u8>> {
// Replace thumbnail link with higher resolution if possible // Replace thumbnail link with higher resolution if possible
let high_res_link = image_link let high_res_link = image_link
@ -100,7 +113,7 @@ impl GoogleBooksClient {
let response = let response =
self.client.get(&high_res_link).send().await.map_err(|e| { self.client.get(&high_res_link).send().await.map_err(|e| {
PinakesError::External(format!("Cover download failed: {}", e)) PinakesError::External(format!("Cover download failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -111,7 +124,7 @@ impl GoogleBooksClient {
} }
response.bytes().await.map(|b| b.to_vec()).map_err(|e| { response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
PinakesError::External(format!("Failed to read cover data: {}", e)) PinakesError::External(format!("Failed to read cover data: {e}"))
}) })
} }
} }
@ -201,6 +214,7 @@ pub struct ImageLinks {
impl ImageLinks { impl ImageLinks {
/// Get the best available image link (highest resolution) /// Get the best available image link (highest resolution)
#[must_use]
pub fn best_link(&self) -> Option<&String> { pub fn best_link(&self) -> Option<&String> {
self self
.extra_large .extra_large
@ -223,11 +237,13 @@ pub struct IndustryIdentifier {
impl IndustryIdentifier { impl IndustryIdentifier {
/// Check if this is an ISBN-13 /// Check if this is an ISBN-13
#[must_use]
pub fn is_isbn13(&self) -> bool { pub fn is_isbn13(&self) -> bool {
self.identifier_type == "ISBN_13" self.identifier_type == "ISBN_13"
} }
/// Check if this is an ISBN-10 /// Check if this is an ISBN-10
#[must_use]
pub fn is_isbn10(&self) -> bool { pub fn is_isbn10(&self) -> bool {
self.identifier_type == "ISBN_10" self.identifier_type == "ISBN_10"
} }

View file

@ -18,13 +18,16 @@ pub struct LastFmEnricher {
} }
impl LastFmEnricher { impl LastFmEnricher {
/// Create a new `LastFmEnricher`.
#[must_use]
pub fn new(api_key: String) -> Self { pub fn new(api_key: String) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
client: reqwest::Client::builder() client,
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.build()
.expect("failed to build HTTP client with configured timeouts"),
api_key, api_key,
base_url: "https://ws.audioscrobbler.com/2.0".to_string(), base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
} }
@ -87,9 +90,8 @@ impl MetadataEnricher for LastFmEnricher {
return Ok(None); return Ok(None);
} }
let track = match json.get("track") { let Some(track) = json.get("track") else {
Some(t) => t, return Ok(None);
None => return Ok(None),
}; };
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);

View file

@ -1,8 +1,10 @@
use std::fmt::Write as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::{PinakesError, Result}; use crate::error::{PinakesError, Result};
/// OpenLibrary API client for book metadata enrichment /// `OpenLibrary` API client for book metadata enrichment
pub struct OpenLibraryClient { pub struct OpenLibraryClient {
client: reqwest::Client, client: reqwest::Client,
base_url: String, base_url: String,
@ -15,23 +17,31 @@ impl Default for OpenLibraryClient {
} }
impl OpenLibraryClient { impl OpenLibraryClient {
/// Create a new `OpenLibraryClient`.
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
let client = reqwest::Client::builder()
.user_agent("Pinakes/1.0")
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
client: reqwest::Client::builder() client,
.user_agent("Pinakes/1.0")
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client"),
base_url: "https://openlibrary.org".to_string(), base_url: "https://openlibrary.org".to_string(),
} }
} }
/// Fetch book metadata by ISBN /// Fetch book metadata by ISBN
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// parsed.
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> { pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> {
let url = format!("{}/isbn/{}.json", self.base_url, isbn); let url = format!("{}/isbn/{}.json", self.base_url, isbn);
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("OpenLibrary request failed: {}", e)) PinakesError::External(format!("OpenLibrary request failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -43,13 +53,17 @@ impl OpenLibraryClient {
response.json::<OpenLibraryBook>().await.map_err(|e| { response.json::<OpenLibraryBook>().await.map_err(|e| {
PinakesError::External(format!( PinakesError::External(format!(
"Failed to parse OpenLibrary response: {}", "Failed to parse OpenLibrary response: {e}"
e
)) ))
}) })
} }
/// Search for books by title and author /// Search for books by title and author
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// parsed.
pub async fn search( pub async fn search(
&self, &self,
title: &str, title: &str,
@ -62,13 +76,13 @@ impl OpenLibraryClient {
); );
if let Some(author) = author { if let Some(author) = author {
url.push_str(&format!("&author={}", urlencoding::encode(author))); let _ = write!(url, "&author={}", urlencoding::encode(author));
} }
url.push_str("&limit=5"); url.push_str("&limit=5");
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("OpenLibrary search failed: {}", e)) PinakesError::External(format!("OpenLibrary search failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -80,13 +94,18 @@ impl OpenLibraryClient {
let search_response: OpenLibrarySearchResponse = let search_response: OpenLibrarySearchResponse =
response.json().await.map_err(|e| { response.json().await.map_err(|e| {
PinakesError::External(format!("Failed to parse search results: {}", e)) PinakesError::External(format!("Failed to parse search results: {e}"))
})?; })?;
Ok(search_response.docs) Ok(search_response.docs)
} }
/// Fetch cover image by cover ID /// Fetch cover image by cover ID
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// read.
pub async fn fetch_cover( pub async fn fetch_cover(
&self, &self,
cover_id: i64, cover_id: i64,
@ -98,13 +117,11 @@ impl OpenLibraryClient {
CoverSize::Large => "L", CoverSize::Large => "L",
}; };
let url = format!( let url =
"https://covers.openlibrary.org/b/id/{}-{}.jpg", format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg");
cover_id, size_str
);
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("Cover download failed: {}", e)) PinakesError::External(format!("Cover download failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -115,11 +132,16 @@ impl OpenLibraryClient {
} }
response.bytes().await.map(|b| b.to_vec()).map_err(|e| { response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
PinakesError::External(format!("Failed to read cover data: {}", e)) PinakesError::External(format!("Failed to read cover data: {e}"))
}) })
} }
/// Fetch cover by ISBN /// Fetch cover by ISBN
///
/// # Errors
///
/// Returns an error if the HTTP request fails or the response cannot be
/// read.
pub async fn fetch_cover_by_isbn( pub async fn fetch_cover_by_isbn(
&self, &self,
isbn: &str, isbn: &str,
@ -131,13 +153,11 @@ impl OpenLibraryClient {
CoverSize::Large => "L", CoverSize::Large => "L",
}; };
let url = format!( let url =
"https://covers.openlibrary.org/b/isbn/{}-{}.jpg", format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg");
isbn, size_str
);
let response = self.client.get(&url).send().await.map_err(|e| { let response = self.client.get(&url).send().await.map_err(|e| {
PinakesError::External(format!("Cover download failed: {}", e)) PinakesError::External(format!("Cover download failed: {e}"))
})?; })?;
if !response.status().is_success() { if !response.status().is_success() {
@ -148,7 +168,7 @@ impl OpenLibraryClient {
} }
response.bytes().await.map(|b| b.to_vec()).map_err(|e| { response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
PinakesError::External(format!("Failed to read cover data: {}", e)) PinakesError::External(format!("Failed to read cover data: {e}"))
}) })
} }
} }
@ -220,6 +240,7 @@ pub enum StringOrObject {
} }
impl StringOrObject { impl StringOrObject {
#[must_use]
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
Self::String(s) => s, Self::String(s) => s,

View file

@ -18,6 +18,13 @@ pub struct TmdbEnricher {
} }
impl TmdbEnricher { impl TmdbEnricher {
/// Create a new `TMDb` enricher.
///
/// # Panics
///
/// Panics if the HTTP client cannot be built (programming error in client
/// configuration).
#[must_use]
pub fn new(api_key: String) -> Self { pub fn new(api_key: String) -> Self {
Self { Self {
client: reqwest::Client::builder() client: reqwest::Client::builder()
@ -50,7 +57,7 @@ impl MetadataEnricher for TmdbEnricher {
.get(&url) .get(&url)
.query(&[ .query(&[
("api_key", &self.api_key), ("api_key", &self.api_key),
("query", &title.to_string()), ("query", &title.clone()),
("page", &"1".to_string()), ("page", &"1".to_string()),
]) ])
.send() .send()
@ -85,7 +92,7 @@ impl MetadataEnricher for TmdbEnricher {
})?; })?;
let results = json.get("results").and_then(|r| r.as_array()); let results = json.get("results").and_then(|r| r.as_array());
if results.is_none_or(|r| r.is_empty()) { if results.is_none_or(std::vec::Vec::is_empty) {
return Ok(None); return Ok(None);
} }
@ -93,13 +100,14 @@ impl MetadataEnricher for TmdbEnricher {
return Ok(None); return Ok(None);
}; };
let movie = &results[0]; let movie = &results[0];
let external_id = match movie.get("id").and_then(|id| id.as_i64()) { let external_id = match movie.get("id").and_then(serde_json::Value::as_i64)
{
Some(id) => id.to_string(), Some(id) => id.to_string(),
None => return Ok(None), None => return Ok(None),
}; };
let popularity = movie let popularity = movie
.get("popularity") .get("popularity")
.and_then(|p| p.as_f64()) .and_then(serde_json::Value::as_f64)
.unwrap_or(0.0); .unwrap_or(0.0);
// Normalize popularity to 0-1 range (TMDB popularity can be very high) // Normalize popularity to 0-1 range (TMDB popularity can be very high)
let confidence = (popularity / 100.0).min(1.0); let confidence = (popularity / 100.0).min(1.0);

View file

@ -112,19 +112,19 @@ pub enum PinakesError {
impl From<rusqlite::Error> for PinakesError { impl From<rusqlite::Error> for PinakesError {
fn from(e: rusqlite::Error) -> Self { fn from(e: rusqlite::Error) -> Self {
PinakesError::Database(e.to_string()) Self::Database(e.to_string())
} }
} }
impl From<tokio_postgres::Error> for PinakesError { impl From<tokio_postgres::Error> for PinakesError {
fn from(e: tokio_postgres::Error) -> Self { fn from(e: tokio_postgres::Error) -> Self {
PinakesError::Database(e.to_string()) Self::Database(e.to_string())
} }
} }
impl From<serde_json::Error> for PinakesError { impl From<serde_json::Error> for PinakesError {
fn from(e: serde_json::Error) -> Self { fn from(e: serde_json::Error) -> Self {
PinakesError::Serialization(e.to_string()) Self::Serialization(e.to_string())
} }
} }

View file

@ -55,10 +55,12 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let dlat = (lat2 - lat1).to_radians(); let dlat = (lat2 - lat1).to_radians();
let dlon = (lon2 - lon1).to_radians(); let dlon = (lon2 - lon1).to_radians();
let a = (dlat / 2.0).sin().powi(2) let a = (dlat / 2.0).sin().mul_add(
+ lat1.to_radians().cos() (dlat / 2.0).sin(),
lat1.to_radians().cos()
* lat2.to_radians().cos() * lat2.to_radians().cos()
* (dlon / 2.0).sin().powi(2); * (dlon / 2.0).sin().powi(2),
);
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
@ -127,7 +129,8 @@ pub fn detect_events(
if let (Some((lat1, lon1)), Some((lat2, lon2))) = if let (Some((lat1, lon1)), Some((lat2, lon2))) =
(current_location, item.latitude.zip(item.longitude)) (current_location, item.latitude.zip(item.longitude))
{ {
current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0)); current_location =
Some((f64::midpoint(lat1, lat2), f64::midpoint(lon1, lon2)));
} else if item.latitude.is_some() && item.longitude.is_some() { } else if item.latitude.is_some() && item.longitude.is_some() {
current_location = item.latitude.zip(item.longitude); current_location = item.latitude.zip(item.longitude);
} }

View file

@ -38,6 +38,7 @@ pub async fn compute_file_hash(path: &Path) -> Result<ContentHash> {
} }
/// Computes the BLAKE3 hash of a byte slice synchronously. /// Computes the BLAKE3 hash of a byte slice synchronously.
#[must_use]
pub fn compute_hash_sync(data: &[u8]) -> ContentHash { pub fn compute_hash_sync(data: &[u8]) -> ContentHash {
let hash = blake3::hash(data); let hash = blake3::hash(data);
ContentHash::new(hash.to_hex().to_string()) ContentHash::new(hash.to_hex().to_string())

View file

@ -1,5 +1,6 @@
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
time::SystemTime, time::SystemTime,
}; };
@ -12,7 +13,14 @@ use crate::{
links, links,
media_type::{BuiltinMediaType, MediaType}, media_type::{BuiltinMediaType, MediaType},
metadata, metadata,
model::*, model::{
AuditAction,
CustomField,
CustomFieldType,
MediaId,
MediaItem,
StorageMode,
},
storage::DynStorageBackend, storage::DynStorageBackend,
thumbnail, thumbnail,
}; };
@ -43,7 +51,7 @@ fn get_file_mtime(path: &Path) -> Option<i64> {
.ok() .ok()
.and_then(|m| m.modified().ok()) .and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64) .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
} }
/// Validates that a path is within configured root directories. /// Validates that a path is within configured root directories.
@ -103,6 +111,10 @@ pub async fn import_file(
} }
/// Import a file with configurable options for incremental scanning /// Import a file with configurable options for incremental scanning
///
/// # Errors
///
/// Returns [`PinakesError`] if the file cannot be read, hashed, or stored.
pub async fn import_file_with_options( pub async fn import_file_with_options(
storage: &DynStorageBackend, storage: &DynStorageBackend,
path: &Path, path: &Path,
@ -161,7 +173,7 @@ pub async fn import_file_with_options(
let path_clone = path.clone(); let path_clone = path.clone();
let media_type_clone = media_type.clone(); let media_type_clone = media_type.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
metadata::extract_metadata(&path_clone, media_type_clone) metadata::extract_metadata(&path_clone, &media_type_clone)
}) })
.await .await
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
@ -185,7 +197,7 @@ pub async fn import_file_with_options(
thumbnail::generate_thumbnail( thumbnail::generate_thumbnail(
media_id, media_id,
&source, &source,
media_type_clone, &media_type_clone,
&thumb_dir, &thumb_dir,
) )
}) })
@ -194,7 +206,7 @@ pub async fn import_file_with_options(
}; };
// Generate perceptual hash for image files (if enabled in config) // Generate perceptual hash for image files (if enabled in config)
let perceptual_hash = if options.photo_config.generate_perceptual_hash let perceptual_hash = if options.photo_config.generate_perceptual_hash()
&& media_type.category() == crate::media_type::MediaCategory::Image && media_type.category() == crate::media_type::MediaCategory::Image
{ {
crate::metadata::image::generate_perceptual_hash(&path) crate::metadata::image::generate_perceptual_hash(&path)
@ -327,6 +339,12 @@ pub(crate) fn should_ignore(
/// Default number of concurrent import tasks. /// Default number of concurrent import tasks.
const DEFAULT_IMPORT_CONCURRENCY: usize = 8; const DEFAULT_IMPORT_CONCURRENCY: usize = 8;
/// Import all supported files in a directory with default options.
///
/// # Errors
///
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
/// fail.
pub async fn import_directory( pub async fn import_directory(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -342,6 +360,13 @@ pub async fn import_directory(
.await .await
} }
/// Import all supported files in a directory with a specified concurrency
/// limit.
///
/// # Errors
///
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
/// fail.
pub async fn import_directory_with_concurrency( pub async fn import_directory_with_concurrency(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -358,7 +383,12 @@ pub async fn import_directory_with_concurrency(
.await .await
} }
/// Import a directory with full options including incremental scanning support /// Import a directory with full options including incremental scanning support.
///
/// # Errors
///
/// Returns [`PinakesError`] if the directory cannot be read or spawned tasks
/// fail.
pub async fn import_directory_with_options( pub async fn import_directory_with_options(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -377,8 +407,8 @@ pub async fn import_directory_with_options(
walkdir::WalkDir::new(&dir) walkdir::WalkDir::new(&dir)
.follow_links(true) .follow_links(true)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_file()) .filter(|e| !e.file_type().is_dir())
.filter(|e| MediaType::from_path(e.path()).is_some()) .filter(|e| MediaType::from_path(e.path()).is_some())
.filter(|e| !should_ignore(e.path(), &patterns)) .filter(|e| !should_ignore(e.path(), &patterns))
.map(|e| e.path().to_path_buf()) .map(|e| e.path().to_path_buf())
@ -392,7 +422,7 @@ pub async fn import_directory_with_options(
let mut join_set = tokio::task::JoinSet::new(); let mut join_set = tokio::task::JoinSet::new();
for entry_path in entries { for entry_path in entries {
let storage = storage.clone(); let storage = Arc::clone(storage);
let path = entry_path.clone(); let path = entry_path.clone();
let opts = options.clone(); let opts = options.clone();

View file

@ -85,6 +85,10 @@ impl std::str::FromStr for IntegrityStatus {
/// # Returns /// # Returns
/// ///
/// Report containing orphaned items, untracked files, and moved files /// Report containing orphaned items, untracked files, and moved files
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn detect_orphans( pub async fn detect_orphans(
storage: &DynStorageBackend, storage: &DynStorageBackend,
) -> Result<OrphanReport> { ) -> Result<OrphanReport> {
@ -283,6 +287,10 @@ async fn detect_untracked_files(
} }
/// Resolve orphaned media items by deleting them from the database. /// Resolve orphaned media items by deleting them from the database.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn resolve_orphans( pub async fn resolve_orphans(
storage: &DynStorageBackend, storage: &DynStorageBackend,
action: OrphanAction, action: OrphanAction,
@ -302,6 +310,10 @@ pub async fn resolve_orphans(
} }
/// Verify integrity of media files by recomputing hashes and comparing. /// Verify integrity of media files by recomputing hashes and comparing.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn verify_integrity( pub async fn verify_integrity(
storage: &DynStorageBackend, storage: &DynStorageBackend,
media_ids: Option<&[MediaId]>, media_ids: Option<&[MediaId]>,
@ -361,6 +373,11 @@ pub async fn verify_integrity(
} }
/// Clean up orphaned thumbnail files that don't correspond to any media item. /// Clean up orphaned thumbnail files that don't correspond to any media item.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation or
/// filesystem access fails.
pub async fn cleanup_orphaned_thumbnails( pub async fn cleanup_orphaned_thumbnails(
storage: &DynStorageBackend, storage: &DynStorageBackend,
thumbnail_dir: &Path, thumbnail_dir: &Path,

View file

@ -15,18 +15,17 @@ use uuid::Uuid;
use crate::model::{LinkType, MarkdownLink, MediaId}; use crate::model::{LinkType, MarkdownLink, MediaId};
// Compile regexes once at startup to avoid recompilation on every call // Compile regexes once at startup to avoid recompilation on every call.
static WIKILINK_RE: LazyLock<Regex> = LazyLock::new(|| { // Stored as Option so that initialization failure is handled gracefully
Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid wikilink regex") // rather than panicking.
}); static WIKILINK_RE: LazyLock<Option<Regex>> =
LazyLock::new(|| Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").ok());
static EMBED_RE: LazyLock<Regex> = LazyLock::new(|| { static EMBED_RE: LazyLock<Option<Regex>> =
Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").expect("valid embed regex") LazyLock::new(|| Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").ok());
});
static MARKDOWN_LINK_RE: LazyLock<Regex> = LazyLock::new(|| { static MARKDOWN_LINK_RE: LazyLock<Option<Regex>> =
Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid markdown link regex") LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").ok());
});
/// Configuration for context extraction around links /// Configuration for context extraction around links
const CONTEXT_CHARS_BEFORE: usize = 50; const CONTEXT_CHARS_BEFORE: usize = 50;
@ -38,6 +37,7 @@ const CONTEXT_CHARS_AFTER: usize = 50;
/// - Wikilinks: `[[target]]` and `[[target|display text]]` /// - Wikilinks: `[[target]]` and `[[target|display text]]`
/// - Embeds: `![[target]]` /// - Embeds: `![[target]]`
/// - Markdown links: `[text](path)` (internal paths only, no http/https) /// - Markdown links: `[text](path)` (internal paths only, no http/https)
#[must_use]
pub fn extract_links( pub fn extract_links(
source_media_id: MediaId, source_media_id: MediaId,
content: &str, content: &str,
@ -63,10 +63,13 @@ fn extract_wikilinks(
source_media_id: MediaId, source_media_id: MediaId,
content: &str, content: &str,
) -> Vec<MarkdownLink> { ) -> Vec<MarkdownLink> {
let Some(re) = WIKILINK_RE.as_ref() else {
return Vec::new();
};
let mut links = Vec::new(); let mut links = Vec::new();
for (line_num, line) in content.lines().enumerate() { for (line_num, line) in content.lines().enumerate() {
for cap in WIKILINK_RE.captures_iter(line) { for cap in re.captures_iter(line) {
let Some(full_match) = cap.get(0) else { let Some(full_match) = cap.get(0) else {
continue; continue;
}; };
@ -100,7 +103,11 @@ fn extract_wikilinks(
target_media_id: None, // Will be resolved later target_media_id: None, // Will be resolved later
link_type: LinkType::Wikilink, link_type: LinkType::Wikilink,
link_text: display_text.or_else(|| Some(target.to_string())), link_text: display_text.or_else(|| Some(target.to_string())),
line_number: Some(line_num as i32 + 1), // 1-indexed line_number: Some(
i32::try_from(line_num)
.unwrap_or(i32::MAX)
.saturating_add(1),
), // 1-indexed
context: Some(context), context: Some(context),
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}); });
@ -116,10 +123,13 @@ fn extract_embeds(
source_media_id: MediaId, source_media_id: MediaId,
content: &str, content: &str,
) -> Vec<MarkdownLink> { ) -> Vec<MarkdownLink> {
let Some(re) = EMBED_RE.as_ref() else {
return Vec::new();
};
let mut links = Vec::new(); let mut links = Vec::new();
for (line_num, line) in content.lines().enumerate() { for (line_num, line) in content.lines().enumerate() {
for cap in EMBED_RE.captures_iter(line) { for cap in re.captures_iter(line) {
let Some(full_match) = cap.get(0) else { let Some(full_match) = cap.get(0) else {
continue; continue;
}; };
@ -143,7 +153,11 @@ fn extract_embeds(
target_media_id: None, target_media_id: None,
link_type: LinkType::Embed, link_type: LinkType::Embed,
link_text: display_text.or_else(|| Some(target.to_string())), link_text: display_text.or_else(|| Some(target.to_string())),
line_number: Some(line_num as i32 + 1), line_number: Some(
i32::try_from(line_num)
.unwrap_or(i32::MAX)
.saturating_add(1),
),
context: Some(context), context: Some(context),
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}); });
@ -159,10 +173,13 @@ fn extract_markdown_links(
source_media_id: MediaId, source_media_id: MediaId,
content: &str, content: &str,
) -> Vec<MarkdownLink> { ) -> Vec<MarkdownLink> {
let Some(re) = MARKDOWN_LINK_RE.as_ref() else {
return Vec::new();
};
let mut links = Vec::new(); let mut links = Vec::new();
for (line_num, line) in content.lines().enumerate() { for (line_num, line) in content.lines().enumerate() {
for cap in MARKDOWN_LINK_RE.captures_iter(line) { for cap in re.captures_iter(line) {
let Some(full_match) = cap.get(0) else { let Some(full_match) = cap.get(0) else {
continue; continue;
}; };
@ -215,7 +232,11 @@ fn extract_markdown_links(
target_media_id: None, target_media_id: None,
link_type: LinkType::MarkdownLink, link_type: LinkType::MarkdownLink,
link_text: Some(text.to_string()), link_text: Some(text.to_string()),
line_number: Some(line_num as i32 + 1), line_number: Some(
i32::try_from(line_num)
.unwrap_or(i32::MAX)
.saturating_add(1),
),
context: Some(context), context: Some(context),
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}); });
@ -278,6 +299,7 @@ pub enum ResolutionStrategy {
/// Resolve a link target to possible file paths. /// Resolve a link target to possible file paths.
/// ///
/// Returns a list of candidate paths to check, in order of preference. /// Returns a list of candidate paths to check, in order of preference.
#[must_use]
pub fn resolve_link_candidates( pub fn resolve_link_candidates(
target: &str, target: &str,
source_path: &Path, source_path: &Path,
@ -307,7 +329,7 @@ pub fn resolve_link_candidates(
candidates.push(relative.clone()); candidates.push(relative.clone());
// Also try with .md extension // Also try with .md extension
if !target.ends_with(".md") { if !target.to_ascii_lowercase().ends_with(".md") {
candidates.push(relative.with_extension("md")); candidates.push(relative.with_extension("md"));
let mut with_md = relative.clone(); let mut with_md = relative.clone();
with_md.set_file_name(format!( with_md.set_file_name(format!(
@ -319,10 +341,10 @@ pub fn resolve_link_candidates(
} }
// 3. Filename with .md extension in root dirs // 3. Filename with .md extension in root dirs
let target_with_md = if target.ends_with(".md") { let target_with_md = if target.to_ascii_lowercase().ends_with(".md") {
target.to_string() target.to_string()
} else { } else {
format!("{}.md", target) format!("{target}.md")
}; };
for root in root_dirs { for root in root_dirs {
@ -340,6 +362,7 @@ pub fn resolve_link_candidates(
/// ///
/// Obsidian uses the `aliases` field in frontmatter to define alternative names /// Obsidian uses the `aliases` field in frontmatter to define alternative names
/// for a note that can be used in wikilinks. /// for a note that can be used in wikilinks.
#[must_use]
pub fn extract_aliases(content: &str) -> Vec<String> { pub fn extract_aliases(content: &str) -> Vec<String> {
let Ok(parsed) = let Ok(parsed) =
gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(content) gray_matter::Matter::<gray_matter::engine::YAML>::new().parse(content)
@ -441,7 +464,7 @@ mod tests {
#[test] #[test]
fn test_multiple_links() { fn test_multiple_links() {
let content = r#" let content = r"
# My Note # My Note
This links to [[Note A]] and also [[Note B|Note B Title]]. This links to [[Note A]] and also [[Note B|Note B Title]].
@ -449,7 +472,7 @@ This links to [[Note A]] and also [[Note B|Note B Title]].
We also have a markdown link to [config](./config.md). We also have a markdown link to [config](./config.md).
And an embedded image: ![[diagram.png]] And an embedded image: ![[diagram.png]]
"#; ";
let links = extract_links(test_media_id(), content); let links = extract_links(test_media_id(), content);
assert_eq!(links.len(), 4); assert_eq!(links.len(), 4);
@ -488,7 +511,7 @@ And an embedded image: ![[diagram.png]]
#[test] #[test]
fn test_extract_aliases() { fn test_extract_aliases() {
let content = r#"--- let content = r"---
title: My Note title: My Note
aliases: aliases:
- Alternative Name - Alternative Name
@ -496,20 +519,20 @@ aliases:
--- ---
# Content here # Content here
"#; ";
let aliases = extract_aliases(content); let aliases = extract_aliases(content);
assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]); assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]);
} }
#[test] #[test]
fn test_extract_single_alias() { fn test_extract_single_alias() {
let content = r#"--- let content = r"---
title: My Note title: My Note
aliases: Single Alias aliases: Single Alias
--- ---
# Content # Content
"#; ";
let aliases = extract_aliases(content); let aliases = extract_aliases(content);
assert_eq!(aliases, vec!["Single Alias"]); assert_eq!(aliases, vec!["Single Alias"]);
} }
@ -538,7 +561,7 @@ aliases: Single Alias
#[test] #[test]
fn test_exclude_markdown_images() { fn test_exclude_markdown_images() {
// Test that markdown images ![alt](image.png) are NOT extracted as links // Test that markdown images ![alt](image.png) are NOT extracted as links
let content = r#" let content = r"
# My Note # My Note
Here's a regular link: [documentation](docs/guide.md) Here's a regular link: [documentation](docs/guide.md)
@ -551,15 +574,14 @@ Multiple images:
![Logo](logo.png) and ![Banner](banner.jpg) ![Logo](logo.png) and ![Banner](banner.jpg)
Mixed: [link](file.md) then ![image](pic.png) then [another](other.md) Mixed: [link](file.md) then ![image](pic.png) then [another](other.md)
"#; ";
let links = extract_links(test_media_id(), content); let links = extract_links(test_media_id(), content);
// Should only extract the 4 markdown links, not the 4 images // Should only extract the 4 markdown links, not the 4 images
assert_eq!( assert_eq!(
links.len(), links.len(),
4, 4,
"Should extract 4 links, not images. Got: {:#?}", "Should extract 4 links, not images. Got: {links:#?}"
links
); );
// Verify all extracted items are MarkdownLink type (not images) // Verify all extracted items are MarkdownLink type (not images)

View file

@ -28,7 +28,8 @@ pub struct ManagedStorageService {
impl ManagedStorageService { impl ManagedStorageService {
/// Create a new managed storage service. /// Create a new managed storage service.
pub fn new( #[must_use]
pub const fn new(
root_dir: PathBuf, root_dir: PathBuf,
max_upload_size: u64, max_upload_size: u64,
verify_on_read: bool, verify_on_read: bool,
@ -41,6 +42,10 @@ impl ManagedStorageService {
} }
/// Initialize the storage directory structure. /// Initialize the storage directory structure.
///
/// # Errors
///
/// Returns [`PinakesError`] if the directory cannot be created.
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
fs::create_dir_all(&self.root_dir).await?; fs::create_dir_all(&self.root_dir).await?;
info!(path = %self.root_dir.display(), "initialized managed storage"); info!(path = %self.root_dir.display(), "initialized managed storage");
@ -50,6 +55,7 @@ impl ManagedStorageService {
/// Get the storage path for a content hash. /// Get the storage path for a content hash.
/// ///
/// Layout: `<root>/<hash[0:2]>/<hash[2:4]>/<full_hash>` /// Layout: `<root>/<hash[0:2]>/<hash[2:4]>/<full_hash>`
#[must_use]
pub fn path(&self, hash: &ContentHash) -> PathBuf { pub fn path(&self, hash: &ContentHash) -> PathBuf {
let h = &hash.0; let h = &hash.0;
if h.len() >= 4 { if h.len() >= 4 {
@ -61,7 +67,8 @@ impl ManagedStorageService {
} }
/// Check if a blob exists in storage. /// Check if a blob exists in storage.
pub async fn exists(&self, hash: &ContentHash) -> bool { #[must_use]
pub fn exists(&self, hash: &ContentHash) -> bool {
self.path(hash).exists() self.path(hash).exists()
} }
@ -70,6 +77,11 @@ impl ManagedStorageService {
/// Returns the content hash and file size. /// Returns the content hash and file size.
/// If the file already exists with the same hash, returns early /// If the file already exists with the same hash, returns early
/// (deduplication). /// (deduplication).
///
/// # Errors
///
/// Returns [`PinakesError`] if the file cannot be stored or exceeds the size
/// limit.
pub async fn store_stream<R: AsyncRead + Unpin>( pub async fn store_stream<R: AsyncRead + Unpin>(
&self, &self,
mut reader: R, mut reader: R,
@ -119,14 +131,13 @@ impl ManagedStorageService {
debug!(hash = %hash, "blob already exists, deduplicating"); debug!(hash = %hash, "blob already exists, deduplicating");
let _ = fs::remove_file(&temp_path).await; let _ = fs::remove_file(&temp_path).await;
return Ok((hash, total_size)); return Ok((hash, total_size));
} else {
warn!(
hash = %hash,
expected = total_size,
actual = existing_meta.len(),
"size mismatch for existing blob, replacing"
);
} }
warn!(
hash = %hash,
expected = total_size,
actual = existing_meta.len(),
"size mismatch for existing blob, replacing"
);
} }
// Move temp file to final location // Move temp file to final location
@ -140,6 +151,10 @@ impl ManagedStorageService {
} }
/// Store a file from a path. /// Store a file from a path.
///
/// # Errors
///
/// Returns [`PinakesError`] if the file cannot be opened or stored.
pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> { pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> {
let file = fs::File::open(path).await?; let file = fs::File::open(path).await?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
@ -147,6 +162,11 @@ impl ManagedStorageService {
} }
/// Store bytes directly. /// Store bytes directly.
///
/// # Errors
///
/// Returns [`PinakesError`] if the data cannot be stored or exceeds the size
/// limit.
pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> { pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> {
use std::io::Cursor; use std::io::Cursor;
let cursor = Cursor::new(data); let cursor = Cursor::new(data);
@ -154,6 +174,10 @@ impl ManagedStorageService {
} }
/// Open a blob for reading. /// Open a blob for reading.
///
/// # Errors
///
/// Returns [`PinakesError`] if the blob does not exist or cannot be opened.
pub async fn open(&self, hash: &ContentHash) -> Result<fs::File> { pub async fn open(&self, hash: &ContentHash) -> Result<fs::File> {
let path = self.path(hash); let path = self.path(hash);
if !path.exists() { if !path.exists() {
@ -168,6 +192,11 @@ impl ManagedStorageService {
} }
/// Read a blob entirely into memory. /// Read a blob entirely into memory.
///
/// # Errors
///
/// Returns [`PinakesError`] if the blob does not exist, cannot be read, or
/// fails integrity check.
pub async fn read(&self, hash: &ContentHash) -> Result<Vec<u8>> { pub async fn read(&self, hash: &ContentHash) -> Result<Vec<u8>> {
let path = self.path(hash); let path = self.path(hash);
if !path.exists() { if !path.exists() {
@ -180,8 +209,7 @@ impl ManagedStorageService {
let computed = blake3::hash(&data); let computed = blake3::hash(&data);
if computed.to_hex().to_string() != hash.0 { if computed.to_hex().to_string() != hash.0 {
return Err(PinakesError::StorageIntegrity(format!( return Err(PinakesError::StorageIntegrity(format!(
"hash mismatch for blob {}", "hash mismatch for blob {hash}"
hash
))); )));
} }
} }
@ -190,6 +218,11 @@ impl ManagedStorageService {
} }
/// Verify the integrity of a stored blob. /// Verify the integrity of a stored blob.
///
/// # Errors
///
/// Returns [`PinakesError`] if the blob cannot be read or has a hash
/// mismatch.
pub async fn verify(&self, hash: &ContentHash) -> Result<bool> { pub async fn verify(&self, hash: &ContentHash) -> Result<bool> {
let path = self.path(hash); let path = self.path(hash);
if !path.exists() { if !path.exists() {
@ -217,8 +250,7 @@ impl ManagedStorageService {
"blob integrity check failed" "blob integrity check failed"
); );
return Err(PinakesError::StorageIntegrity(format!( return Err(PinakesError::StorageIntegrity(format!(
"hash mismatch: expected {}, computed {}", "hash mismatch: expected {hash}, computed {computed}"
hash, computed
))); )));
} }
@ -227,6 +259,10 @@ impl ManagedStorageService {
} }
/// Delete a blob from storage. /// Delete a blob from storage.
///
/// # Errors
///
/// Returns [`PinakesError`] if the blob cannot be removed.
pub async fn delete(&self, hash: &ContentHash) -> Result<()> { pub async fn delete(&self, hash: &ContentHash) -> Result<()> {
let path = self.path(hash); let path = self.path(hash);
if path.exists() { if path.exists() {
@ -245,6 +281,11 @@ impl ManagedStorageService {
} }
/// Get the size of a stored blob. /// Get the size of a stored blob.
///
/// # Errors
///
/// Returns [`PinakesError`] if the blob does not exist or metadata cannot be
/// read.
pub async fn size(&self, hash: &ContentHash) -> Result<u64> { pub async fn size(&self, hash: &ContentHash) -> Result<u64> {
let path = self.path(hash); let path = self.path(hash);
if !path.exists() { if !path.exists() {
@ -255,18 +296,23 @@ impl ManagedStorageService {
} }
/// List all blob hashes in storage. /// List all blob hashes in storage.
///
/// # Errors
///
/// Returns [`PinakesError`] if the storage directory cannot be read.
pub async fn list_all(&self) -> Result<Vec<ContentHash>> { pub async fn list_all(&self) -> Result<Vec<ContentHash>> {
let mut hashes = Vec::new(); let mut hashes = Vec::new();
let mut entries = fs::read_dir(&self.root_dir).await?; let mut entries = fs::read_dir(&self.root_dir).await?;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let path = entry.path(); let path = entry.path();
if path.is_dir() && path.file_name().map(|n| n.len()) == Some(2) { if path.is_dir() && path.file_name().map(std::ffi::OsStr::len) == Some(2)
{
let mut sub_entries = fs::read_dir(&path).await?; let mut sub_entries = fs::read_dir(&path).await?;
while let Some(sub_entry) = sub_entries.next_entry().await? { while let Some(sub_entry) = sub_entries.next_entry().await? {
let sub_path = sub_entry.path(); let sub_path = sub_entry.path();
if sub_path.is_dir() if sub_path.is_dir()
&& sub_path.file_name().map(|n| n.len()) == Some(2) && sub_path.file_name().map(std::ffi::OsStr::len) == Some(2)
{ {
let mut file_entries = fs::read_dir(&sub_path).await?; let mut file_entries = fs::read_dir(&sub_path).await?;
while let Some(file_entry) = file_entries.next_entry().await? { while let Some(file_entry) = file_entries.next_entry().await? {
@ -287,6 +333,10 @@ impl ManagedStorageService {
} }
/// Calculate total storage used by all blobs. /// Calculate total storage used by all blobs.
///
/// # Errors
///
/// Returns [`StorageError`] if listing blobs or querying sizes fails.
pub async fn total_size(&self) -> Result<u64> { pub async fn total_size(&self) -> Result<u64> {
let hashes = self.list_all().await?; let hashes = self.list_all().await?;
let mut total = 0u64; let mut total = 0u64;
@ -299,6 +349,10 @@ impl ManagedStorageService {
} }
/// Clean up any orphaned temp files. /// Clean up any orphaned temp files.
///
/// # Errors
///
/// Returns [`PinakesError`] if the temp directory cannot be read.
pub async fn cleanup_temp(&self) -> Result<u64> { pub async fn cleanup_temp(&self) -> Result<u64> {
let temp_dir = self.root_dir.join("temp"); let temp_dir = self.root_dir.join("temp");
if !temp_dir.exists() { if !temp_dir.exists() {
@ -349,7 +403,7 @@ mod tests {
let (hash, size) = service.store_bytes(data).await.unwrap(); let (hash, size) = service.store_bytes(data).await.unwrap();
assert_eq!(size, data.len() as u64); assert_eq!(size, data.len() as u64);
assert!(service.exists(&hash).await); assert!(service.exists(&hash));
let retrieved = service.read(&hash).await.unwrap(); let retrieved = service.read(&hash).await.unwrap();
assert_eq!(retrieved, data); assert_eq!(retrieved, data);
@ -405,9 +459,9 @@ mod tests {
let data = b"delete me"; let data = b"delete me";
let (hash, _) = service.store_bytes(data).await.unwrap(); let (hash, _) = service.store_bytes(data).await.unwrap();
assert!(service.exists(&hash).await); assert!(service.exists(&hash));
service.delete(&hash).await.unwrap(); service.delete(&hash).await.unwrap();
assert!(!service.exists(&hash).await); assert!(!service.exists(&hash));
} }
} }

View file

@ -62,7 +62,8 @@ pub enum MediaCategory {
impl BuiltinMediaType { impl BuiltinMediaType {
/// Get the unique, stable ID for this media type. /// Get the unique, stable ID for this media type.
pub fn id(&self) -> &'static str { #[must_use]
pub const fn id(&self) -> &'static str {
match self { match self {
Self::Mp3 => "mp3", Self::Mp3 => "mp3",
Self::Flac => "flac", Self::Flac => "flac",
@ -98,6 +99,7 @@ impl BuiltinMediaType {
} }
/// Get the display name for this media type /// Get the display name for this media type
#[must_use]
pub fn name(&self) -> String { pub fn name(&self) -> String {
match self { match self {
Self::Mp3 => "MP3 Audio".to_string(), Self::Mp3 => "MP3 Audio".to_string(),
@ -133,6 +135,7 @@ impl BuiltinMediaType {
} }
} }
#[must_use]
pub fn from_extension(ext: &str) -> Option<Self> { pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() { match ext.to_ascii_lowercase().as_str() {
"mp3" => Some(Self::Mp3), "mp3" => Some(Self::Mp3),
@ -176,7 +179,8 @@ impl BuiltinMediaType {
.and_then(Self::from_extension) .and_then(Self::from_extension)
} }
pub fn mime_type(&self) -> &'static str { #[must_use]
pub const fn mime_type(&self) -> &'static str {
match self { match self {
Self::Mp3 => "audio/mpeg", Self::Mp3 => "audio/mpeg",
Self::Flac => "audio/flac", Self::Flac => "audio/flac",
@ -211,7 +215,8 @@ impl BuiltinMediaType {
} }
} }
pub fn category(&self) -> MediaCategory { #[must_use]
pub const fn category(&self) -> MediaCategory {
match self { match self {
Self::Mp3 Self::Mp3
| Self::Flac | Self::Flac
@ -240,7 +245,8 @@ impl BuiltinMediaType {
} }
} }
pub fn extensions(&self) -> &'static [&'static str] { #[must_use]
pub const fn extensions(&self) -> &'static [&'static str] {
match self { match self {
Self::Mp3 => &["mp3"], Self::Mp3 => &["mp3"],
Self::Flac => &["flac"], Self::Flac => &["flac"],
@ -276,7 +282,8 @@ impl BuiltinMediaType {
} }
/// Returns true if this is a RAW image format. /// Returns true if this is a RAW image format.
pub fn is_raw(&self) -> bool { #[must_use]
pub const fn is_raw(&self) -> bool {
matches!( matches!(
self, self,
Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2

View file

@ -31,6 +31,7 @@ impl MediaType {
} }
/// Get the type ID as a string /// Get the type ID as a string
#[must_use]
pub fn id(&self) -> String { pub fn id(&self) -> String {
match self { match self {
Self::Builtin(b) => b.id().to_string(), Self::Builtin(b) => b.id().to_string(),
@ -40,6 +41,7 @@ impl MediaType {
/// Get the display name for this media type /// Get the display name for this media type
/// For custom types without a registry, returns the ID as the name /// For custom types without a registry, returns the ID as the name
#[must_use]
pub fn name(&self) -> String { pub fn name(&self) -> String {
match self { match self {
Self::Builtin(b) => b.name(), Self::Builtin(b) => b.name(),
@ -48,14 +50,14 @@ impl MediaType {
} }
/// Get the display name for this media type with registry support /// Get the display name for this media type with registry support
#[must_use]
pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String { pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String {
match self { match self {
Self::Builtin(b) => b.name(), Self::Builtin(b) => b.name(),
Self::Custom(id) => { Self::Custom(id) => {
registry registry
.get(id) .get(id)
.map(|d| d.name.clone()) .map_or_else(|| id.clone(), |d| d.name.clone())
.unwrap_or_else(|| id.clone())
}, },
} }
} }
@ -63,7 +65,8 @@ impl MediaType {
/// Get the category for this media type /// Get the category for this media type
/// For custom types without a registry, returns [`MediaCategory::Document`] /// For custom types without a registry, returns [`MediaCategory::Document`]
/// as default /// as default
pub fn category(&self) -> MediaCategory { #[must_use]
pub const fn category(&self) -> MediaCategory {
match self { match self {
Self::Builtin(b) => b.category(), Self::Builtin(b) => b.category(),
Self::Custom(_) => MediaCategory::Document, Self::Custom(_) => MediaCategory::Document,
@ -71,6 +74,7 @@ impl MediaType {
} }
/// Get the category for this media type with registry support /// Get the category for this media type with registry support
#[must_use]
pub fn category_with_registry( pub fn category_with_registry(
&self, &self,
registry: &MediaTypeRegistry, registry: &MediaTypeRegistry,
@ -88,6 +92,7 @@ impl MediaType {
/// Get the MIME type /// Get the MIME type
/// For custom types without a registry, returns "application/octet-stream" /// For custom types without a registry, returns "application/octet-stream"
#[must_use]
pub fn mime_type(&self) -> String { pub fn mime_type(&self) -> String {
match self { match self {
Self::Builtin(b) => b.mime_type().to_string(), Self::Builtin(b) => b.mime_type().to_string(),
@ -96,6 +101,7 @@ impl MediaType {
} }
/// Get the MIME type with registry support /// Get the MIME type with registry support
#[must_use]
pub fn mime_type_with_registry( pub fn mime_type_with_registry(
&self, &self,
registry: &MediaTypeRegistry, registry: &MediaTypeRegistry,
@ -113,23 +119,31 @@ impl MediaType {
/// Get file extensions /// Get file extensions
/// For custom types without a registry, returns an empty vec /// For custom types without a registry, returns an empty vec
#[must_use]
pub fn extensions(&self) -> Vec<String> { pub fn extensions(&self) -> Vec<String> {
match self { match self {
Self::Builtin(b) => { Self::Builtin(b) => {
b.extensions().iter().map(|s| s.to_string()).collect() b.extensions()
.iter()
.map(std::string::ToString::to_string)
.collect()
}, },
Self::Custom(_) => vec![], Self::Custom(_) => vec![],
} }
} }
/// Get file extensions with registry support /// Get file extensions with registry support
#[must_use]
pub fn extensions_with_registry( pub fn extensions_with_registry(
&self, &self,
registry: &MediaTypeRegistry, registry: &MediaTypeRegistry,
) -> Vec<String> { ) -> Vec<String> {
match self { match self {
Self::Builtin(b) => { Self::Builtin(b) => {
b.extensions().iter().map(|s| s.to_string()).collect() b.extensions()
.iter()
.map(std::string::ToString::to_string)
.collect()
}, },
Self::Custom(id) => { Self::Custom(id) => {
registry registry
@ -141,7 +155,8 @@ impl MediaType {
} }
/// Check if this is a RAW image format /// Check if this is a RAW image format
pub fn is_raw(&self) -> bool { #[must_use]
pub const fn is_raw(&self) -> bool {
match self { match self {
Self::Builtin(b) => b.is_raw(), Self::Builtin(b) => b.is_raw(),
Self::Custom(_) => false, Self::Custom(_) => false,
@ -149,13 +164,14 @@ impl MediaType {
} }
/// Resolve a media type from file extension (built-in types only) /// Resolve a media type from file extension (built-in types only)
/// Use from_extension_with_registry for custom types /// Use `from_extension_with_registry` for custom types
pub fn from_extension(ext: &str) -> Option<Self> { pub fn from_extension(ext: &str) -> Option<Self> {
BuiltinMediaType::from_extension(ext).map(Self::Builtin) BuiltinMediaType::from_extension(ext).map(Self::Builtin)
} }
/// Resolve a media type from file extension with registry (includes custom /// Resolve a media type from file extension with registry (includes custom
/// types) /// types)
#[must_use]
pub fn from_extension_with_registry( pub fn from_extension_with_registry(
ext: &str, ext: &str,
registry: &MediaTypeRegistry, registry: &MediaTypeRegistry,
@ -172,7 +188,7 @@ impl MediaType {
} }
/// Resolve a media type from file path (built-in types only) /// Resolve a media type from file path (built-in types only)
/// Use from_path_with_registry for custom types /// Use `from_path_with_registry` for custom types
pub fn from_path(path: &Path) -> Option<Self> { pub fn from_path(path: &Path) -> Option<Self> {
path path
.extension() .extension()
@ -181,6 +197,7 @@ impl MediaType {
} }
/// Resolve a media type from file path with registry (includes custom types) /// Resolve a media type from file path with registry (includes custom types)
#[must_use]
pub fn from_path_with_registry( pub fn from_path_with_registry(
path: &Path, path: &Path,
registry: &MediaTypeRegistry, registry: &MediaTypeRegistry,

View file

@ -41,6 +41,7 @@ pub struct MediaTypeRegistry {
impl MediaTypeRegistry { impl MediaTypeRegistry {
/// Create a new empty registry /// Create a new empty registry
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
types: HashMap::new(), types: HashMap::new(),
@ -78,7 +79,7 @@ impl MediaTypeRegistry {
let descriptor = self let descriptor = self
.types .types
.remove(id) .remove(id)
.ok_or_else(|| anyhow!("Media type not found: {}", id))?; .ok_or_else(|| anyhow!("Media type not found: {id}"))?;
// Remove extensions // Remove extensions
for ext in &descriptor.extensions { for ext in &descriptor.extensions {
@ -92,11 +93,13 @@ impl MediaTypeRegistry {
} }
/// Get a media type descriptor by ID /// Get a media type descriptor by ID
#[must_use]
pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> { pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> {
self.types.get(id) self.types.get(id)
} }
/// Get a media type by file extension /// Get a media type by file extension
#[must_use]
pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> { pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> {
let ext_lower = ext.to_lowercase(); let ext_lower = ext.to_lowercase();
self self
@ -106,11 +109,13 @@ impl MediaTypeRegistry {
} }
/// List all registered media types /// List all registered media types
#[must_use]
pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> { pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> {
self.types.values().collect() self.types.values().collect()
} }
/// List media types from a specific plugin /// List media types from a specific plugin
#[must_use]
pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> { pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> {
self self
.types .types
@ -119,7 +124,8 @@ impl MediaTypeRegistry {
.collect() .collect()
} }
/// List built-in media types (plugin_id is None) /// List built-in media types (`plugin_id` is None)
#[must_use]
pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> { pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> {
self self
.types .types
@ -129,11 +135,13 @@ impl MediaTypeRegistry {
} }
/// Get count of registered types /// Get count of registered types
#[must_use]
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.types.len() self.types.len()
} }
/// Check if a media type is registered /// Check if a media type is registered
#[must_use]
pub fn contains(&self, id: &str) -> bool { pub fn contains(&self, id: &str) -> bool {
self.types.contains_key(id) self.types.contains_key(id)
} }
@ -170,7 +178,7 @@ mod tests {
fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor { fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor {
MediaTypeDescriptor { MediaTypeDescriptor {
id: id.to_string(), id: id.to_string(),
name: format!("{} Type", id), name: format!("{id} Type"),
category: Some(MediaCategory::Document), category: Some(MediaCategory::Document),
extensions: vec![ext.to_string()], extensions: vec![ext.to_string()],
mime_types: vec![format!("application/{}", id)], mime_types: vec![format!("application/{}", id)],
@ -183,7 +191,7 @@ mod tests {
let mut registry = MediaTypeRegistry::new(); let mut registry = MediaTypeRegistry::new();
let descriptor = create_test_descriptor("test", "tst"); let descriptor = create_test_descriptor("test", "tst");
registry.register(descriptor.clone()).unwrap(); registry.register(descriptor).unwrap();
let retrieved = registry.get("test").unwrap(); let retrieved = registry.get("test").unwrap();
assert_eq!(retrieved.id, "test"); assert_eq!(retrieved.id, "test");
@ -271,8 +279,8 @@ mod tests {
for i in 1..=3 { for i in 1..=3 {
let desc = MediaTypeDescriptor { let desc = MediaTypeDescriptor {
id: format!("type{}", i), id: format!("type{i}"),
name: format!("Type {}", i), name: format!("Type {i}"),
category: Some(MediaCategory::Document), category: Some(MediaCategory::Document),
extensions: vec![format!("t{}", i)], extensions: vec![format!("t{}", i)],
mime_types: vec![format!("application/type{}", i)], mime_types: vec![format!("application/type{}", i)],

View file

@ -29,7 +29,7 @@ impl MetadataExtractor for AudioExtractor {
meta.artist = tag.artist().map(|s| s.to_string()); meta.artist = tag.artist().map(|s| s.to_string());
meta.album = tag.album().map(|s| s.to_string()); meta.album = tag.album().map(|s| s.to_string());
meta.genre = tag.genre().map(|s| s.to_string()); meta.genre = tag.genre().map(|s| s.to_string());
meta.year = tag.date().map(|ts| ts.year as i32); meta.year = tag.date().map(|ts| i32::from(ts.year));
} }
if let Some(tag) = tagged_file if let Some(tag) = tagged_file

View file

@ -15,11 +15,11 @@ impl MetadataExtractor for ImageExtractor {
let file = std::fs::File::open(path)?; let file = std::fs::File::open(path)?;
let mut buf_reader = std::io::BufReader::new(&file); let mut buf_reader = std::io::BufReader::new(&file);
let exif_data = let Ok(exif_data) =
match exif::Reader::new().read_from_container(&mut buf_reader) { exif::Reader::new().read_from_container(&mut buf_reader)
Ok(exif) => exif, else {
Err(_) => return Ok(meta), return Ok(meta);
}; };
// Image dimensions // Image dimensions
if let Some(width) = exif_data if let Some(width) = exif_data
@ -226,7 +226,7 @@ impl MetadataExtractor for ImageExtractor {
fn field_to_u32(field: &exif::Field) -> Option<u32> { fn field_to_u32(field: &exif::Field) -> Option<u32> {
match &field.value { match &field.value {
exif::Value::Long(v) => v.first().copied(), exif::Value::Long(v) => v.first().copied(),
exif::Value::Short(v) => v.first().map(|&x| x as u32), exif::Value::Short(v) => v.first().map(|&x| u32::from(x)),
_ => None, _ => None,
} }
} }
@ -274,9 +274,11 @@ fn parse_exif_datetime(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
} }
/// Generate a perceptual hash for an image file. /// Generate a perceptual hash for an image file.
///
/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity /// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity
/// detection. Returns a hex-encoded hash string, or None if the image cannot be /// detection. Returns a hex-encoded hash string, or None if the image cannot be
/// processed. /// processed.
#[must_use]
pub fn generate_perceptual_hash(path: &Path) -> Option<String> { pub fn generate_perceptual_hash(path: &Path) -> Option<String> {
use image_hasher::{HashAlg, HasherConfig}; use image_hasher::{HashAlg, HasherConfig};

View file

@ -34,13 +34,25 @@ pub struct ExtractedMetadata {
} }
pub trait MetadataExtractor: Send + Sync { pub trait MetadataExtractor: Send + Sync {
/// Extract metadata from a file at the given path.
///
/// # Errors
///
/// Returns an error if the file cannot be read or parsed.
fn extract(&self, path: &Path) -> Result<ExtractedMetadata>; fn extract(&self, path: &Path) -> Result<ExtractedMetadata>;
fn supported_types(&self) -> Vec<MediaType>; fn supported_types(&self) -> Vec<MediaType>;
} }
/// Extract metadata from a file using the appropriate extractor for the given
/// media type.
///
/// # Errors
///
/// Returns an error if no extractor supports the media type, or if extraction
/// fails.
pub fn extract_metadata( pub fn extract_metadata(
path: &Path, path: &Path,
media_type: MediaType, media_type: &MediaType,
) -> Result<ExtractedMetadata> { ) -> Result<ExtractedMetadata> {
let extractors: Vec<Box<dyn MetadataExtractor>> = vec![ let extractors: Vec<Box<dyn MetadataExtractor>> = vec![
Box::new(audio::AudioExtractor), Box::new(audio::AudioExtractor),
@ -51,7 +63,7 @@ pub fn extract_metadata(
]; ];
for extractor in &extractors { for extractor in &extractors {
if extractor.supported_types().contains(&media_type) { if extractor.supported_types().contains(media_type) {
return extractor.extract(path); return extractor.extract(path);
} }
} }

View file

@ -53,7 +53,7 @@ fn extract_mkv(path: &Path) -> Result<ExtractedMetadata> {
matroska::Settings::Audio(a) => { matroska::Settings::Audio(a) => {
meta.extra.insert( meta.extra.insert(
"sample_rate".to_string(), "sample_rate".to_string(),
format!("{} Hz", a.sample_rate as u32), format!("{:.0} Hz", a.sample_rate),
); );
meta meta
.extra .extra
@ -64,7 +64,7 @@ fn extract_mkv(path: &Path) -> Result<ExtractedMetadata> {
.insert("audio_codec".to_string(), track.codec_id.clone()); .insert("audio_codec".to_string(), track.codec_id.clone());
} }
}, },
_ => {}, matroska::Settings::None => {},
} }
} }
@ -99,7 +99,7 @@ fn extract_mp4(path: &Path) -> Result<ExtractedMetadata> {
meta.genre = tag meta.genre = tag
.genre() .genre()
.map(|s: std::borrow::Cow<'_, str>| s.to_string()); .map(|s: std::borrow::Cow<'_, str>| s.to_string());
meta.year = tag.date().map(|ts| ts.year as i32); meta.year = tag.date().map(|ts| i32::from(ts.year));
} }
let properties = tagged_file.properties(); let properties = tagged_file.properties();

View file

@ -67,6 +67,7 @@ impl Opener for WindowsOpener {
} }
/// Returns the platform-appropriate opener. /// Returns the platform-appropriate opener.
#[must_use]
pub fn default_opener() -> Box<dyn Opener> { pub fn default_opener() -> Box<dyn Opener> {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
Box::new(MacOpener) Box::new(MacOpener)

View file

@ -33,6 +33,14 @@ use crate::error::{PinakesError, Result};
/// The canonicalized path if valid, or a `PathNotAllowed` error if the path /// The canonicalized path if valid, or a `PathNotAllowed` error if the path
/// is outside all allowed roots. /// is outside all allowed roots.
/// ///
/// # Errors
///
/// Returns a `PathNotAllowed` error if:
/// - No allowed roots are configured
/// - The path does not exist
/// - The path cannot be canonicalized
/// - The path is outside all allowed roots
///
/// # Example /// # Example
/// ///
/// ```no_run /// ```no_run
@ -106,6 +114,11 @@ pub fn validate_path(
/// ///
/// This is a convenience wrapper for `validate_path` when you only have one /// This is a convenience wrapper for `validate_path` when you only have one
/// root. /// root.
///
/// # Errors
///
/// Returns a `PathNotAllowed` error if the path is outside the root directory
/// or cannot be canonicalized.
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> { pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
validate_path(path, &[root.to_path_buf()]) validate_path(path, &[root.to_path_buf()])
} }
@ -125,6 +138,7 @@ pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
/// ///
/// `true` if the path appears safe (no obvious traversal sequences), /// `true` if the path appears safe (no obvious traversal sequences),
/// `false` if it contains suspicious patterns. /// `false` if it contains suspicious patterns.
#[must_use]
pub fn path_looks_safe(path: &str) -> bool { pub fn path_looks_safe(path: &str) -> bool {
// Reject paths with obvious traversal patterns // Reject paths with obvious traversal patterns
!path.contains("..") !path.contains("..")
@ -148,6 +162,7 @@ pub fn path_looks_safe(path: &str) -> bool {
/// # Returns /// # Returns
/// ///
/// A sanitized filename safe for use on most filesystems. /// A sanitized filename safe for use on most filesystems.
#[must_use]
pub fn sanitize_filename(filename: &str) -> String { pub fn sanitize_filename(filename: &str) -> String {
let sanitized: String = filename let sanitized: String = filename
.chars() .chars()
@ -186,6 +201,14 @@ pub fn sanitize_filename(filename: &str) -> String {
/// ///
/// The joined path if safe, or an error if the relative path would escape the /// The joined path if safe, or an error if the relative path would escape the
/// base. /// base.
///
/// # Errors
///
/// Returns a `PathNotAllowed` error if:
/// - The relative path is absolute
/// - The relative path contains `..`
/// - The base path cannot be canonicalized
/// - A path traversal is detected
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> { pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
// Reject absolute paths in the relative component // Reject absolute paths in the relative component
if relative.starts_with('/') || relative.starts_with('\\') { if relative.starts_with('/') || relative.starts_with('\\') {
@ -215,7 +238,7 @@ pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
// The joined path might not exist yet, so we can't canonicalize it directly. // The joined path might not exist yet, so we can't canonicalize it directly.
// Instead, we check each component // Instead, we check each component
let mut current = canonical_base.clone(); let mut current = canonical_base;
for component in Path::new(relative).components() { for component in Path::new(relative).components() {
use std::path::Component; use std::path::Component;
match component { match component {
@ -227,7 +250,7 @@ pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
"path traversal detected".to_string(), "path traversal detected".to_string(),
)); ));
}, },
Component::CurDir => continue, Component::CurDir => {},
_ => { _ => {
return Err(PinakesError::PathNotAllowed( return Err(PinakesError::PathNotAllowed(
"invalid path component".to_string(), "invalid path component".to_string(),

View file

@ -15,12 +15,17 @@ pub struct PluginLoader {
impl PluginLoader { impl PluginLoader {
/// Create a new plugin loader /// Create a new plugin loader
pub fn new(plugin_dirs: Vec<PathBuf>) -> Self { #[must_use]
pub const fn new(plugin_dirs: Vec<PathBuf>) -> Self {
Self { plugin_dirs } Self { plugin_dirs }
} }
/// Discover all plugins in configured directories /// Discover all plugins in configured directories
pub async fn discover_plugins(&self) -> Result<Vec<PluginManifest>> { ///
/// # Errors
///
/// Returns an error if a plugin directory cannot be searched.
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
let mut manifests = Vec::new(); let mut manifests = Vec::new();
for dir in &self.plugin_dirs { for dir in &self.plugin_dirs {
@ -31,25 +36,16 @@ impl PluginLoader {
info!("Discovering plugins in: {:?}", dir); info!("Discovering plugins in: {:?}", dir);
match self.discover_in_directory(dir).await { let found = Self::discover_in_directory(dir);
Ok(found) => { info!("Found {} plugins in {:?}", found.len(), dir);
info!("Found {} plugins in {:?}", found.len(), dir); manifests.extend(found);
manifests.extend(found);
},
Err(e) => {
warn!("Error discovering plugins in {:?}: {}", dir, e);
},
}
} }
Ok(manifests) Ok(manifests)
} }
/// Discover plugins in a specific directory /// Discover plugins in a specific directory
async fn discover_in_directory( fn discover_in_directory(dir: &Path) -> Vec<PluginManifest> {
&self,
dir: &Path,
) -> Result<Vec<PluginManifest>> {
let mut manifests = Vec::new(); let mut manifests = Vec::new();
// Walk the directory looking for plugin.toml files // Walk the directory looking for plugin.toml files
@ -83,10 +79,15 @@ impl PluginLoader {
} }
} }
Ok(manifests) manifests
} }
/// Resolve the WASM binary path from a manifest /// Resolve the WASM binary path from a manifest
///
/// # Errors
///
/// Returns an error if the WASM binary is not found or its path escapes the
/// plugin directory.
pub fn resolve_wasm_path( pub fn resolve_wasm_path(
&self, &self,
manifest: &PluginManifest, manifest: &PluginManifest,
@ -114,14 +115,14 @@ impl PluginLoader {
// traversal) // traversal)
let canonical_wasm = wasm_path let canonical_wasm = wasm_path
.canonicalize() .canonicalize()
.map_err(|e| anyhow!("Failed to canonicalize WASM path: {}", e))?; .map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?;
let canonical_plugin_dir = plugin_dir let canonical_plugin_dir = plugin_dir
.canonicalize() .canonicalize()
.map_err(|e| anyhow!("Failed to canonicalize plugin dir: {}", e))?; .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?;
if !canonical_wasm.starts_with(&canonical_plugin_dir) { if !canonical_wasm.starts_with(&canonical_plugin_dir) {
return Err(anyhow!( return Err(anyhow!(
"WASM binary path escapes plugin directory: {:?}", "WASM binary path escapes plugin directory: {}",
wasm_path wasm_path.display()
)); ));
} }
return Ok(canonical_wasm); return Ok(canonical_wasm);
@ -135,12 +136,19 @@ impl PluginLoader {
} }
/// Download a plugin from a URL /// Download a plugin from a URL
///
/// # Errors
///
/// Returns an error if the URL is not HTTPS, no plugin directories are
/// configured, the download fails, the archive is too large, or extraction
/// fails.
pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> { pub async fn download_plugin(&self, url: &str) -> Result<PathBuf> {
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
// Only allow HTTPS downloads // Only allow HTTPS downloads
if !url.starts_with("https://") { if !url.starts_with("https://") {
return Err(anyhow!( return Err(anyhow!(
"Only HTTPS URLs are allowed for plugin downloads: {}", "Only HTTPS URLs are allowed for plugin downloads: {url}"
url
)); ));
} }
@ -153,15 +161,15 @@ impl PluginLoader {
// Download the archive with timeout and size limits // Download the archive with timeout and size limits
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) .timeout(std::time::Duration::from_mins(5))
.build() .build()
.map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?; .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?;
let response = client let response = client
.get(url) .get(url)
.send() .send()
.await .await
.map_err(|e| anyhow!("Failed to download plugin: {}", e))?; .map_err(|e| anyhow!("Failed to download plugin: {e}"))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(anyhow!( return Err(anyhow!(
@ -171,21 +179,19 @@ impl PluginLoader {
} }
// Check content-length header before downloading // Check content-length header before downloading
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
if let Some(content_length) = response.content_length() if let Some(content_length) = response.content_length()
&& content_length > MAX_PLUGIN_SIZE && content_length > MAX_PLUGIN_SIZE
{ {
return Err(anyhow!( return Err(anyhow!(
"Plugin archive too large: {} bytes (max {} bytes)", "Plugin archive too large: {content_length} bytes (max \
content_length, {MAX_PLUGIN_SIZE} bytes)"
MAX_PLUGIN_SIZE
)); ));
} }
let bytes = response let bytes = response
.bytes() .bytes()
.await .await
.map_err(|e| anyhow!("Failed to read plugin response: {}", e))?; .map_err(|e| anyhow!("Failed to read plugin response: {e}"))?;
// Check actual size after download // Check actual size after download
if bytes.len() as u64 > MAX_PLUGIN_SIZE { if bytes.len() as u64 > MAX_PLUGIN_SIZE {
@ -204,7 +210,7 @@ impl PluginLoader {
// Extract using tar with -C to target directory // Extract using tar with -C to target directory
let canonical_dest = dest_dir let canonical_dest = dest_dir
.canonicalize() .canonicalize()
.map_err(|e| anyhow!("Failed to canonicalize dest dir: {}", e))?; .map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?;
let output = std::process::Command::new("tar") let output = std::process::Command::new("tar")
.args([ .args([
"xzf", "xzf",
@ -213,7 +219,7 @@ impl PluginLoader {
&canonical_dest.to_string_lossy(), &canonical_dest.to_string_lossy(),
]) ])
.output() .output()
.map_err(|e| anyhow!("Failed to extract plugin archive: {}", e))?; .map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?;
// Clean up the archive // Clean up the archive
let _ = std::fs::remove_file(&temp_archive); let _ = std::fs::remove_file(&temp_archive);
@ -231,8 +237,8 @@ impl PluginLoader {
let entry_canonical = entry.path().canonicalize()?; let entry_canonical = entry.path().canonicalize()?;
if !entry_canonical.starts_with(&canonical_dest) { if !entry_canonical.starts_with(&canonical_dest) {
return Err(anyhow!( return Err(anyhow!(
"Extracted file escapes destination directory: {:?}", "Extracted file escapes destination directory: {}",
entry.path() entry.path().display()
)); ));
} }
} }
@ -255,22 +261,26 @@ impl PluginLoader {
} }
Err(anyhow!( Err(anyhow!(
"No plugin.toml found after extracting archive from: {}", "No plugin.toml found after extracting archive from: {url}"
url
)) ))
} }
/// Validate a plugin package /// Validate a plugin package
///
/// # Errors
///
/// Returns an error if the path does not exist, is missing `plugin.toml`,
/// the WASM binary is not found, or the WASM file is invalid.
pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { pub fn validate_plugin_package(&self, path: &Path) -> Result<()> {
// Check that the path exists // Check that the path exists
if !path.exists() { if !path.exists() {
return Err(anyhow!("Plugin path does not exist: {:?}", path)); return Err(anyhow!("Plugin path does not exist: {}", path.display()));
} }
// Check for plugin.toml // Check for plugin.toml
let manifest_path = path.join("plugin.toml"); let manifest_path = path.join("plugin.toml");
if !manifest_path.exists() { if !manifest_path.exists() {
return Err(anyhow!("Missing plugin.toml in {:?}", path)); return Err(anyhow!("Missing plugin.toml in {}", path.display()));
} }
// Parse and validate manifest // Parse and validate manifest
@ -291,21 +301,22 @@ impl PluginLoader {
let canonical_path = path.canonicalize()?; let canonical_path = path.canonicalize()?;
if !canonical_wasm.starts_with(&canonical_path) { if !canonical_wasm.starts_with(&canonical_path) {
return Err(anyhow!( return Err(anyhow!(
"WASM binary path escapes plugin directory: {:?}", "WASM binary path escapes plugin directory: {}",
wasm_path wasm_path.display()
)); ));
} }
// Validate WASM file // Validate WASM file
let wasm_bytes = std::fs::read(&wasm_path)?; let wasm_bytes = std::fs::read(&wasm_path)?;
if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" {
return Err(anyhow!("Invalid WASM file: {:?}", wasm_path)); return Err(anyhow!("Invalid WASM file: {}", wasm_path.display()));
} }
Ok(()) Ok(())
} }
/// Get plugin directory path for a given plugin name /// Get plugin directory path for a given plugin name
#[must_use]
pub fn get_plugin_dir(&self, plugin_name: &str) -> Option<PathBuf> { pub fn get_plugin_dir(&self, plugin_name: &str) -> Option<PathBuf> {
for dir in &self.plugin_dirs { for dir in &self.plugin_dirs {
let plugin_dir = dir.join(plugin_name); let plugin_dir = dir.join(plugin_name);
@ -323,17 +334,17 @@ mod tests {
use super::*; use super::*;
#[tokio::test] #[test]
async fn test_discover_plugins_empty() { fn test_discover_plugins_empty() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
let manifests = loader.discover_plugins().await.unwrap(); let manifests = loader.discover_plugins().unwrap();
assert_eq!(manifests.len(), 0); assert_eq!(manifests.len(), 0);
} }
#[tokio::test] #[test]
async fn test_discover_plugins_with_manifest() { fn test_discover_plugins_with_manifest() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let plugin_dir = temp_dir.path().join("test-plugin"); let plugin_dir = temp_dir.path().join("test-plugin");
std::fs::create_dir(&plugin_dir).unwrap(); std::fs::create_dir(&plugin_dir).unwrap();
@ -356,7 +367,7 @@ wasm = "plugin.wasm"
.unwrap(); .unwrap();
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
let manifests = loader.discover_plugins().await.unwrap(); let manifests = loader.discover_plugins().unwrap();
assert_eq!(manifests.len(), 1); assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].plugin.name, "test-plugin"); assert_eq!(manifests[0].plugin.name, "test-plugin");

View file

@ -97,6 +97,11 @@ impl From<crate::config::PluginsConfig> for PluginManagerConfig {
impl PluginManager { impl PluginManager {
/// Create a new plugin manager /// Create a new plugin manager
///
/// # Errors
///
/// Returns an error if the data or cache directories cannot be created, or
/// if the WASM runtime cannot be initialized.
pub fn new( pub fn new(
data_dir: PathBuf, data_dir: PathBuf,
cache_dir: PathBuf, cache_dir: PathBuf,
@ -123,10 +128,14 @@ impl PluginManager {
} }
/// Discover and load all plugins from configured directories /// Discover and load all plugins from configured directories
///
/// # Errors
///
/// Returns an error if plugin discovery fails.
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> { pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
info!("Discovering plugins from {:?}", self.config.plugin_dirs); info!("Discovering plugins from {:?}", self.config.plugin_dirs);
let manifests = self.loader.discover_plugins().await?; let manifests = self.loader.discover_plugins()?;
let mut loaded_plugins = Vec::new(); let mut loaded_plugins = Vec::new();
for manifest in manifests { for manifest in manifests {
@ -145,6 +154,12 @@ impl PluginManager {
} }
/// Load a plugin from a manifest file /// Load a plugin from a manifest file
///
/// # Errors
///
/// Returns an error if the plugin ID is invalid, capability validation
/// fails, the WASM binary cannot be loaded, or the plugin cannot be
/// registered.
async fn load_plugin_from_manifest( async fn load_plugin_from_manifest(
&self, &self,
manifest: &pinakes_plugin_api::PluginManifest, manifest: &pinakes_plugin_api::PluginManifest,
@ -156,7 +171,7 @@ impl PluginManager {
|| plugin_id.contains('\\') || plugin_id.contains('\\')
|| plugin_id.contains("..") || plugin_id.contains("..")
{ {
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}"));
} }
// Check if already loaded // Check if already loaded
@ -202,7 +217,7 @@ impl PluginManager {
// Load WASM binary // Load WASM binary
let wasm_path = self.loader.resolve_wasm_path(manifest)?; let wasm_path = self.loader.resolve_wasm_path(manifest)?;
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?; let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?;
// Initialize plugin // Initialize plugin
let init_succeeded = match wasm_plugin let init_succeeded = match wasm_plugin
@ -246,13 +261,20 @@ impl PluginManager {
enabled: init_succeeded, enabled: init_succeeded,
}; };
let mut registry = self.registry.write().await; {
registry.register(registered)?; let mut registry = self.registry.write().await;
registry.register(registered)?;
}
Ok(plugin_id) Ok(plugin_id)
} }
/// Install a plugin from a file or URL /// Install a plugin from a file or URL
///
/// # Errors
///
/// Returns an error if the plugin cannot be downloaded, the manifest cannot
/// be read, or the plugin cannot be loaded.
pub async fn install_plugin(&self, source: &str) -> Result<String> { pub async fn install_plugin(&self, source: &str) -> Result<String> {
info!("Installing plugin from: {}", source); info!("Installing plugin from: {}", source);
@ -276,13 +298,18 @@ impl PluginManager {
} }
/// Uninstall a plugin /// Uninstall a plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is invalid, the plugin cannot be shut
/// down, cannot be unregistered, or its data directories cannot be removed.
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
// Validate plugin_id to prevent path traversal // Validate plugin_id to prevent path traversal
if plugin_id.contains('/') if plugin_id.contains('/')
|| plugin_id.contains('\\') || plugin_id.contains('\\')
|| plugin_id.contains("..") || plugin_id.contains("..")
{ {
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}"));
} }
info!("Uninstalling plugin: {}", plugin_id); info!("Uninstalling plugin: {}", plugin_id);
@ -291,8 +318,10 @@ impl PluginManager {
self.shutdown_plugin(plugin_id).await?; self.shutdown_plugin(plugin_id).await?;
// Remove from registry // Remove from registry
let mut registry = self.registry.write().await; {
registry.unregister(plugin_id)?; let mut registry = self.registry.write().await;
registry.unregister(plugin_id)?;
}
// Remove plugin data and cache // Remove plugin data and cache
let plugin_data_dir = self.data_dir.join(plugin_id); let plugin_data_dir = self.data_dir.join(plugin_id);
@ -309,37 +338,55 @@ impl PluginManager {
} }
/// Enable a plugin /// Enable a plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is not found in the registry.
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
let mut registry = self.registry.write().await; let mut registry = self.registry.write().await;
registry.enable(plugin_id) registry.enable(plugin_id)
} }
/// Disable a plugin /// Disable a plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is not found in the registry.
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
let mut registry = self.registry.write().await; let mut registry = self.registry.write().await;
registry.disable(plugin_id) registry.disable(plugin_id)
} }
/// Shutdown a specific plugin /// Shutdown a specific plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is not found in the registry.
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
debug!("Shutting down plugin: {}", plugin_id); debug!("Shutting down plugin: {}", plugin_id);
let registry = self.registry.read().await; let registry = self.registry.read().await;
if let Some(plugin) = registry.get(plugin_id) { if let Some(plugin) = registry.get(plugin_id) {
plugin.wasm_plugin.call_function("shutdown", &[]).await.ok(); let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await;
Ok(()) Ok(())
} else { } else {
Err(anyhow::anyhow!("Plugin not found: {}", plugin_id)) Err(anyhow::anyhow!("Plugin not found: {plugin_id}"))
} }
} }
/// Shutdown all plugins /// Shutdown all plugins
///
/// # Errors
///
/// This function always returns `Ok(())`. Individual plugin shutdown errors
/// are logged but do not cause the overall operation to fail.
pub async fn shutdown_all(&self) -> Result<()> { pub async fn shutdown_all(&self) -> Result<()> {
info!("Shutting down all plugins"); info!("Shutting down all plugins");
let registry = self.registry.read().await; let plugin_ids: Vec<String> = {
let plugin_ids: Vec<String> = let registry = self.registry.read().await;
registry.list_all().iter().map(|p| p.id.clone()).collect(); registry.list_all().iter().map(|p| p.id.clone()).collect()
};
for plugin_id in plugin_ids { for plugin_id in plugin_ids {
if let Err(e) = self.shutdown_plugin(&plugin_id).await { if let Err(e) = self.shutdown_plugin(&plugin_id).await {
@ -373,6 +420,11 @@ impl PluginManager {
} }
/// Reload a plugin (for hot-reload during development) /// Reload a plugin (for hot-reload during development)
///
/// # Errors
///
/// Returns an error if hot-reload is disabled, the plugin is not found, it
/// cannot be shut down, or the reloaded plugin cannot be registered.
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
if !self.config.enable_hot_reload { if !self.config.enable_hot_reload {
return Err(anyhow::anyhow!("Hot-reload is disabled")); return Err(anyhow::anyhow!("Hot-reload is disabled"));
@ -387,15 +439,21 @@ impl PluginManager {
let plugin = registry let plugin = registry
.get(plugin_id) .get(plugin_id)
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
if let Some(ref manifest_path) = plugin.manifest_path { let manifest = plugin.manifest_path.as_ref().map_or_else(
pinakes_plugin_api::PluginManifest::from_file(manifest_path) || plugin.manifest.clone(),
.unwrap_or_else(|e| { |manifest_path| {
warn!("Failed to re-read manifest from disk, using cached: {}", e); pinakes_plugin_api::PluginManifest::from_file(manifest_path)
plugin.manifest.clone() .unwrap_or_else(|e| {
}) warn!(
} else { "Failed to re-read manifest from disk, using cached: {}",
plugin.manifest.clone() e
} );
plugin.manifest.clone()
})
},
);
drop(registry);
manifest
}; };
// Shutdown and unload current version // Shutdown and unload current version

View file

@ -26,6 +26,7 @@ pub struct PluginRegistry {
impl PluginRegistry { impl PluginRegistry {
/// Create a new empty registry /// Create a new empty registry
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
plugins: HashMap::new(), plugins: HashMap::new(),
@ -33,6 +34,10 @@ impl PluginRegistry {
} }
/// Register a new plugin /// Register a new plugin
///
/// # Errors
///
/// Returns an error if a plugin with the same ID is already registered.
pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> {
if self.plugins.contains_key(&plugin.id) { if self.plugins.contains_key(&plugin.id) {
return Err(anyhow!("Plugin already registered: {}", plugin.id)); return Err(anyhow!("Plugin already registered: {}", plugin.id));
@ -43,15 +48,20 @@ impl PluginRegistry {
} }
/// Unregister a plugin by ID /// Unregister a plugin by ID
///
/// # Errors
///
/// Returns an error if the plugin ID is not found.
pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { pub fn unregister(&mut self, plugin_id: &str) -> Result<()> {
self self
.plugins .plugins
.remove(plugin_id) .remove(plugin_id)
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
Ok(()) Ok(())
} }
/// Get a plugin by ID /// Get a plugin by ID
#[must_use]
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
self.plugins.get(plugin_id) self.plugins.get(plugin_id)
} }
@ -62,48 +72,61 @@ impl PluginRegistry {
} }
/// Check if a plugin is loaded /// Check if a plugin is loaded
#[must_use]
pub fn is_loaded(&self, plugin_id: &str) -> bool { pub fn is_loaded(&self, plugin_id: &str) -> bool {
self.plugins.contains_key(plugin_id) self.plugins.contains_key(plugin_id)
} }
/// Check if a plugin is enabled. Returns `None` if the plugin is not found. /// Check if a plugin is enabled. Returns `None` if the plugin is not found.
#[must_use]
pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> { pub fn is_enabled(&self, plugin_id: &str) -> Option<bool> {
self.plugins.get(plugin_id).map(|p| p.enabled) self.plugins.get(plugin_id).map(|p| p.enabled)
} }
/// Enable a plugin /// Enable a plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is not found.
pub fn enable(&mut self, plugin_id: &str) -> Result<()> { pub fn enable(&mut self, plugin_id: &str) -> Result<()> {
let plugin = self let plugin = self
.plugins .plugins
.get_mut(plugin_id) .get_mut(plugin_id)
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
plugin.enabled = true; plugin.enabled = true;
Ok(()) Ok(())
} }
/// Disable a plugin /// Disable a plugin
///
/// # Errors
///
/// Returns an error if the plugin ID is not found.
pub fn disable(&mut self, plugin_id: &str) -> Result<()> { pub fn disable(&mut self, plugin_id: &str) -> Result<()> {
let plugin = self let plugin = self
.plugins .plugins
.get_mut(plugin_id) .get_mut(plugin_id)
.ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?;
plugin.enabled = false; plugin.enabled = false;
Ok(()) Ok(())
} }
/// List all registered plugins /// List all registered plugins
#[must_use]
pub fn list_all(&self) -> Vec<&RegisteredPlugin> { pub fn list_all(&self) -> Vec<&RegisteredPlugin> {
self.plugins.values().collect() self.plugins.values().collect()
} }
/// List all enabled plugins /// List all enabled plugins
#[must_use]
pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> {
self.plugins.values().filter(|p| p.enabled).collect() self.plugins.values().filter(|p| p.enabled).collect()
} }
/// Get plugins by kind (e.g., "media_type", "metadata_extractor") /// Get plugins by kind (e.g., "`media_type`", "`metadata_extractor`")
#[must_use]
pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> {
self self
.plugins .plugins
@ -113,11 +136,13 @@ impl PluginRegistry {
} }
/// Get count of registered plugins /// Get count of registered plugins
#[must_use]
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.plugins.len() self.plugins.len()
} }
/// Get count of enabled plugins /// Get count of enabled plugins
#[must_use]
pub fn count_enabled(&self) -> usize { pub fn count_enabled(&self) -> usize {
self.plugins.values().filter(|p| p.enabled).count() self.plugins.values().filter(|p| p.enabled).count()
} }
@ -182,7 +207,7 @@ mod tests {
let plugin = let plugin =
create_test_plugin("test-plugin", vec!["media_type".to_string()]); create_test_plugin("test-plugin", vec!["media_type".to_string()]);
registry.register(plugin.clone()).unwrap(); registry.register(plugin).unwrap();
assert!(registry.is_loaded("test-plugin")); assert!(registry.is_loaded("test-plugin"));
assert!(registry.get("test-plugin").is_some()); assert!(registry.get("test-plugin").is_some());

View file

@ -4,7 +4,7 @@ use std::{path::Path, sync::Arc};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use pinakes_plugin_api::PluginContext; use pinakes_plugin_api::PluginContext;
use wasmtime::*; use wasmtime::{Caller, Config, Engine, Linker, Module, Store, Val, anyhow};
/// WASM runtime wrapper for executing plugins /// WASM runtime wrapper for executing plugins
pub struct WasmRuntime { pub struct WasmRuntime {
@ -13,6 +13,11 @@ pub struct WasmRuntime {
impl WasmRuntime { impl WasmRuntime {
/// Create a new WASM runtime /// Create a new WASM runtime
///
/// # Errors
///
/// Returns an error if the WASM engine cannot be created with the given
/// configuration.
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let mut config = Config::new(); let mut config = Config::new();
config.wasm_component_model(true); config.wasm_component_model(true);
@ -25,13 +30,18 @@ impl WasmRuntime {
} }
/// Load a plugin from a WASM file /// Load a plugin from a WASM file
pub async fn load_plugin( ///
/// # Errors
///
/// Returns an error if the WASM file does not exist, cannot be read, or
/// cannot be compiled.
pub fn load_plugin(
&self, &self,
wasm_path: &Path, wasm_path: &Path,
context: PluginContext, context: PluginContext,
) -> Result<WasmPlugin> { ) -> Result<WasmPlugin> {
if !wasm_path.exists() { if !wasm_path.exists() {
return Err(anyhow!("WASM file not found: {:?}", wasm_path)); return Err(anyhow!("WASM file not found: {}", wasm_path.display()));
} }
let wasm_bytes = std::fs::read(wasm_path)?; let wasm_bytes = std::fs::read(wasm_path)?;
@ -59,7 +69,8 @@ pub struct WasmPlugin {
impl WasmPlugin { impl WasmPlugin {
/// Get the plugin context /// Get the plugin context
pub fn context(&self) -> &PluginContext { #[must_use]
pub const fn context(&self) -> &PluginContext {
&self.context &self.context
} }
@ -67,6 +78,11 @@ impl WasmPlugin {
/// ///
/// Creates a fresh store and instance per invocation with host functions /// Creates a fresh store and instance per invocation with host functions
/// linked, calls the requested exported function, and returns the result. /// linked, calls the requested exported function, and returns the result.
///
/// # Errors
///
/// Returns an error if the function cannot be found, instantiation fails,
/// or the function call returns an error.
pub async fn call_function( pub async fn call_function(
&self, &self,
function_name: &str, function_name: &str,
@ -105,19 +121,23 @@ impl WasmPlugin {
let offset = if let Ok(alloc) = let offset = if let Ok(alloc) =
instance.get_typed_func::<i32, i32>(&mut store, "alloc") instance.get_typed_func::<i32, i32>(&mut store, "alloc")
{ {
let result = alloc.call_async(&mut store, params.len() as i32).await?; let result = alloc
.call_async(
&mut store,
i32::try_from(params.len()).unwrap_or(i32::MAX),
)
.await?;
if result < 0 { if result < 0 {
return Err(anyhow!( return Err(anyhow!(
"plugin alloc returned negative offset: {}", "plugin alloc returned negative offset: {result}"
result
)); ));
} }
result as usize u32::try_from(result).unwrap_or(0) as usize
} else { } else {
0 0
}; };
alloc_offset = offset as i32; alloc_offset = i32::try_from(offset).unwrap_or(i32::MAX);
let mem_data = mem.data_mut(&mut store); let mem_data = mem.data_mut(&mut store);
if offset + params.len() <= mem_data.len() { if offset + params.len() <= mem_data.len() {
mem_data[offset..offset + params.len()].copy_from_slice(params); mem_data[offset..offset + params.len()].copy_from_slice(params);
@ -128,7 +148,7 @@ impl WasmPlugin {
instance instance
.get_func(&mut store, function_name) .get_func(&mut store, function_name)
.ok_or_else(|| { .ok_or_else(|| {
anyhow!("exported function '{}' not found", function_name) anyhow!("exported function '{function_name}' not found")
})?; })?;
let func_ty = func.ty(&store); let func_ty = func.ty(&store);
@ -143,7 +163,10 @@ impl WasmPlugin {
func func
.call_async( .call_async(
&mut store, &mut store,
&[Val::I32(alloc_offset), Val::I32(params.len() as i32)], &[
Val::I32(alloc_offset),
Val::I32(i32::try_from(params.len()).unwrap_or(i32::MAX)),
],
&mut results, &mut results,
) )
.await?; .await?;
@ -152,7 +175,7 @@ impl WasmPlugin {
} else { } else {
// Generic: fill with zeroes // Generic: fill with zeroes
let params_vals: Vec<Val> = let params_vals: Vec<Val> =
(0..param_count).map(|_| Val::I32(0)).collect(); std::iter::repeat_n(Val::I32(0), param_count).collect();
func func
.call_async(&mut store, &params_vals, &mut results) .call_async(&mut store, &params_vals, &mut results)
.await?; .await?;
@ -177,7 +200,7 @@ impl WasmPlugin {
impl Default for WasmPlugin { impl Default for WasmPlugin {
fn default() -> Self { fn default() -> Self {
let engine = Engine::default(); let engine = Engine::default();
let module = Module::new(&engine, br#"(module)"#).unwrap(); let module = Module::new(&engine, br"(module)").unwrap();
Self { Self {
module: Arc::new(module), module: Arc::new(module),
@ -198,6 +221,10 @@ impl HostFunctions {
/// Registers all host ABI functions (`host_log`, `host_read_file`, /// Registers all host ABI functions (`host_log`, `host_read_file`,
/// `host_write_file`, `host_http_request`, `host_get_config`, /// `host_write_file`, `host_http_request`, `host_get_config`,
/// `host_get_buffer`) into the given linker. /// `host_get_buffer`) into the given linker.
///
/// # Errors
///
/// Returns an error if any host function cannot be registered in the linker.
pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> { pub fn setup_linker(linker: &mut Linker<PluginStoreData>) -> Result<()> {
linker.func_wrap( linker.func_wrap(
"env", "env",
@ -209,11 +236,13 @@ impl HostFunctions {
if ptr < 0 || len < 0 { if ptr < 0 || len < 0 {
return; return;
} }
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
if let Some(mem) = memory { if let Some(mem) = memory {
let data = mem.data(&caller); let data = mem.data(&caller);
let start = ptr as usize; let start = u32::try_from(ptr).unwrap_or(0) as usize;
let end = start + len as usize; let end = start + u32::try_from(len).unwrap_or(0) as usize;
if end <= data.len() if end <= data.len()
&& let Ok(msg) = std::str::from_utf8(&data[start..end]) && let Ok(msg) = std::str::from_utf8(&data[start..end])
{ {
@ -238,12 +267,14 @@ impl HostFunctions {
if path_ptr < 0 || path_len < 0 { if path_ptr < 0 || path_len < 0 {
return -1; return -1;
} }
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
let Some(mem) = memory else { return -1 }; let Some(mem) = memory else { return -1 };
let data = mem.data(&caller); let data = mem.data(&caller);
let start = path_ptr as usize; let start = u32::try_from(path_ptr).unwrap_or(0) as usize;
let end = start + path_len as usize; let end = start + u32::try_from(path_len).unwrap_or(0) as usize;
if end > data.len() { if end > data.len() {
return -1; return -1;
} }
@ -254,9 +285,8 @@ impl HostFunctions {
}; };
// Canonicalize path before checking permissions to prevent traversal // Canonicalize path before checking permissions to prevent traversal
let path = match std::path::Path::new(&path_str).canonicalize() { let Ok(path) = std::path::Path::new(&path_str).canonicalize() else {
Ok(p) => p, return -1;
Err(_) => return -1,
}; };
// Check read permission against canonicalized path // Check read permission against canonicalized path
@ -276,14 +306,11 @@ impl HostFunctions {
return -2; return -2;
} }
match std::fs::read(&path) { std::fs::read(&path).map_or(-1, |contents| {
Ok(contents) => { let len = i32::try_from(contents.len()).unwrap_or(i32::MAX);
let len = contents.len() as i32; caller.data_mut().exchange_buffer = contents;
caller.data_mut().exchange_buffer = contents; len
len })
},
Err(_) => -1,
}
}, },
)?; )?;
@ -299,14 +326,18 @@ impl HostFunctions {
if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 {
return -1; return -1;
} }
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
let Some(mem) = memory else { return -1 }; let Some(mem) = memory else { return -1 };
let mem_data = mem.data(&caller); let mem_data = mem.data(&caller);
let path_start = path_ptr as usize; let path_start = u32::try_from(path_ptr).unwrap_or(0) as usize;
let path_end = path_start + path_len as usize; let path_end =
let data_start = data_ptr as usize; path_start + u32::try_from(path_len).unwrap_or(0) as usize;
let data_end = data_start + data_len as usize; let data_start = u32::try_from(data_ptr).unwrap_or(0) as usize;
let data_end =
data_start + u32::try_from(data_len).unwrap_or(0) as usize;
if path_end > mem_data.len() || data_end > mem_data.len() { if path_end > mem_data.len() || data_end > mem_data.len() {
return -1; return -1;
@ -369,12 +400,14 @@ impl HostFunctions {
if url_ptr < 0 || url_len < 0 { if url_ptr < 0 || url_len < 0 {
return -1; return -1;
} }
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
let Some(mem) = memory else { return -1 }; let Some(mem) = memory else { return -1 };
let data = mem.data(&caller); let data = mem.data(&caller);
let start = url_ptr as usize; let start = u32::try_from(url_ptr).unwrap_or(0) as usize;
let end = start + url_len as usize; let end = start + u32::try_from(url_len).unwrap_or(0) as usize;
if end > data.len() { if end > data.len() {
return -1; return -1;
} }
@ -413,7 +446,7 @@ impl HostFunctions {
match result { match result {
Ok(Ok(bytes)) => { Ok(Ok(bytes)) => {
let len = bytes.len() as i32; let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
caller.data_mut().exchange_buffer = bytes.to_vec(); caller.data_mut().exchange_buffer = bytes.to_vec();
len len
}, },
@ -421,26 +454,19 @@ impl HostFunctions {
Err(_) => { Err(_) => {
// block_in_place panicked (e.g. current-thread runtime); // block_in_place panicked (e.g. current-thread runtime);
// fall back to blocking client with timeout // fall back to blocking client with timeout
let client = match reqwest::blocking::Client::builder() let Ok(client) = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.build() .build()
{ else {
Ok(c) => c, return -1;
Err(_) => return -1,
}; };
match client.get(&url_str).send() { client.get(&url_str).send().map_or(-1, |resp| {
Ok(resp) => { resp.bytes().map_or(-1, |bytes| {
match resp.bytes() { let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
Ok(bytes) => { caller.data_mut().exchange_buffer = bytes.to_vec();
let len = bytes.len() as i32; len
caller.data_mut().exchange_buffer = bytes.to_vec(); })
len })
},
Err(_) => -1,
}
},
Err(_) => -1,
}
}, },
} }
}, },
@ -456,12 +482,14 @@ impl HostFunctions {
if key_ptr < 0 || key_len < 0 { if key_ptr < 0 || key_len < 0 {
return -1; return -1;
} }
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
let Some(mem) = memory else { return -1 }; let Some(mem) = memory else { return -1 };
let data = mem.data(&caller); let data = mem.data(&caller);
let start = key_ptr as usize; let start = u32::try_from(key_ptr).unwrap_or(0) as usize;
let end = start + key_len as usize; let end = start + u32::try_from(key_len).unwrap_or(0) as usize;
if end > data.len() { if end > data.len() {
return -1; return -1;
} }
@ -471,16 +499,17 @@ impl HostFunctions {
Err(_) => return -1, Err(_) => return -1,
}; };
match caller.data().context.config.get(&key_str) { let bytes = caller
Some(value) => { .data()
let json = value.to_string(); .context
let bytes = json.into_bytes(); .config
let len = bytes.len() as i32; .get(&key_str)
caller.data_mut().exchange_buffer = bytes; .map(|value| value.to_string().into_bytes());
len bytes.map_or(-1, |b| {
}, let len = i32::try_from(b.len()).unwrap_or(i32::MAX);
None => -1, caller.data_mut().exchange_buffer = b;
} len
})
}, },
)?; )?;
@ -495,19 +524,22 @@ impl HostFunctions {
return -1; return -1;
} }
let buf = caller.data().exchange_buffer.clone(); let buf = caller.data().exchange_buffer.clone();
let copy_len = buf.len().min(dest_len as usize); let copy_len =
buf.len().min(u32::try_from(dest_len).unwrap_or(0) as usize);
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); let memory = caller
.get_export("memory")
.and_then(wasmtime::Extern::into_memory);
let Some(mem) = memory else { return -1 }; let Some(mem) = memory else { return -1 };
let mem_data = mem.data_mut(&mut caller); let mem_data = mem.data_mut(&mut caller);
let start = dest_ptr as usize; let start = u32::try_from(dest_ptr).unwrap_or(0) as usize;
if start + copy_len > mem_data.len() { if start + copy_len > mem_data.len() {
return -1; return -1;
} }
mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]);
copy_len as i32 i32::try_from(copy_len).unwrap_or(i32::MAX)
}, },
)?; )?;

View file

@ -25,7 +25,8 @@ pub struct CapabilityEnforcer {
impl CapabilityEnforcer { impl CapabilityEnforcer {
/// Create a new capability enforcer with default limits /// Create a new capability enforcer with default limits
pub fn new() -> Self { #[must_use]
pub const fn new() -> Self {
Self { Self {
max_memory_limit: 512 * 1024 * 1024, // 512 MB max_memory_limit: 512 * 1024 * 1024, // 512 MB
max_cpu_time_limit: 60 * 1000, // 60 seconds max_cpu_time_limit: 60 * 1000, // 60 seconds
@ -36,36 +37,47 @@ impl CapabilityEnforcer {
} }
/// Set maximum memory limit /// Set maximum memory limit
pub fn with_max_memory(mut self, bytes: usize) -> Self { #[must_use]
pub const fn with_max_memory(mut self, bytes: usize) -> Self {
self.max_memory_limit = bytes; self.max_memory_limit = bytes;
self self
} }
/// Set maximum CPU time limit /// Set maximum CPU time limit
pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { #[must_use]
pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
self.max_cpu_time_limit = milliseconds; self.max_cpu_time_limit = milliseconds;
self self
} }
/// Add allowed read path /// Add allowed read path
#[must_use]
pub fn allow_read_path(mut self, path: PathBuf) -> Self { pub fn allow_read_path(mut self, path: PathBuf) -> Self {
self.allowed_read_paths.push(path); self.allowed_read_paths.push(path);
self self
} }
/// Add allowed write path /// Add allowed write path
#[must_use]
pub fn allow_write_path(mut self, path: PathBuf) -> Self { pub fn allow_write_path(mut self, path: PathBuf) -> Self {
self.allowed_write_paths.push(path); self.allowed_write_paths.push(path);
self self
} }
/// Set default network access policy /// Set default network access policy
pub fn with_network_default(mut self, allow: bool) -> Self { #[must_use]
pub const fn with_network_default(mut self, allow: bool) -> Self {
self.allow_network_default = allow; self.allow_network_default = allow;
self self
} }
/// Validate capabilities requested by a plugin /// Validate capabilities requested by a plugin
///
/// # Errors
///
/// Returns an error if the plugin requests capabilities that exceed the
/// configured system limits, such as memory, CPU time, filesystem paths, or
/// network access.
pub fn validate_capabilities( pub fn validate_capabilities(
&self, &self,
capabilities: &Capabilities, capabilities: &Capabilities,
@ -115,8 +127,8 @@ impl CapabilityEnforcer {
for path in &capabilities.filesystem.read { for path in &capabilities.filesystem.read {
if !self.is_read_allowed(path) { if !self.is_read_allowed(path) {
return Err(anyhow!( return Err(anyhow!(
"Plugin requests read access to {:?} which is not in allowed paths", "Plugin requests read access to {} which is not in allowed paths",
path path.display()
)); ));
} }
} }
@ -125,8 +137,8 @@ impl CapabilityEnforcer {
for path in &capabilities.filesystem.write { for path in &capabilities.filesystem.write {
if !self.is_write_allowed(path) { if !self.is_write_allowed(path) {
return Err(anyhow!( return Err(anyhow!(
"Plugin requests write access to {:?} which is not in allowed paths", "Plugin requests write access to {} which is not in allowed paths",
path path.display()
)); ));
} }
} }
@ -135,6 +147,7 @@ impl CapabilityEnforcer {
} }
/// Check if a path is allowed for reading /// Check if a path is allowed for reading
#[must_use]
pub fn is_read_allowed(&self, path: &Path) -> bool { pub fn is_read_allowed(&self, path: &Path) -> bool {
if self.allowed_read_paths.is_empty() { if self.allowed_read_paths.is_empty() {
return false; // deny-all when unconfigured return false; // deny-all when unconfigured
@ -150,6 +163,7 @@ impl CapabilityEnforcer {
} }
/// Check if a path is allowed for writing /// Check if a path is allowed for writing
#[must_use]
pub fn is_write_allowed(&self, path: &Path) -> bool { pub fn is_write_allowed(&self, path: &Path) -> bool {
if self.allowed_write_paths.is_empty() { if self.allowed_write_paths.is_empty() {
return false; // deny-all when unconfigured return false; // deny-all when unconfigured
@ -173,11 +187,13 @@ impl CapabilityEnforcer {
} }
/// Check if network access is allowed for a plugin /// Check if network access is allowed for a plugin
pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { #[must_use]
pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
capabilities.network.enabled && self.allow_network_default capabilities.network.enabled && self.allow_network_default
} }
/// Check if a specific domain is allowed /// Check if a specific domain is allowed
#[must_use]
pub fn is_domain_allowed( pub fn is_domain_allowed(
&self, &self,
capabilities: &Capabilities, capabilities: &Capabilities,
@ -197,11 +213,13 @@ impl CapabilityEnforcer {
.network .network
.allowed_domains .allowed_domains
.as_ref() .as_ref()
.map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain))) .is_some_and(|domains| {
.unwrap_or(false) domains.iter().any(|d| d.eq_ignore_ascii_case(domain))
})
} }
/// Get effective memory limit for a plugin /// Get effective memory limit for a plugin
#[must_use]
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
capabilities capabilities
.max_memory_bytes .max_memory_bytes
@ -210,6 +228,7 @@ impl CapabilityEnforcer {
} }
/// Get effective CPU time limit for a plugin /// Get effective CPU time limit for a plugin
#[must_use]
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
capabilities capabilities
.max_cpu_time_ms .max_cpu_time_ms
@ -264,8 +283,7 @@ mod tests {
let test_file = allowed_dir.join("test.txt"); let test_file = allowed_dir.join("test.txt");
std::fs::write(&test_file, "test").unwrap(); std::fs::write(&test_file, "test").unwrap();
let enforcer = let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir);
CapabilityEnforcer::new().allow_read_path(allowed_dir.clone());
assert!(enforcer.is_read_allowed(&test_file)); assert!(enforcer.is_read_allowed(&test_file));
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));

View file

@ -46,6 +46,7 @@ pub struct ScanProgress {
const MAX_STORED_ERRORS: usize = 100; const MAX_STORED_ERRORS: usize = 100;
impl ScanProgress { impl ScanProgress {
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
is_scanning: Arc::new(AtomicBool::new(false)), is_scanning: Arc::new(AtomicBool::new(false)),
@ -56,6 +57,7 @@ impl ScanProgress {
} }
} }
#[must_use]
pub fn snapshot(&self) -> ScanStatus { pub fn snapshot(&self) -> ScanStatus {
let errors = self let errors = self
.error_messages .error_messages
@ -112,6 +114,10 @@ impl Default for ScanProgress {
/// # Returns /// # Returns
/// ///
/// Scan status with counts and any errors /// Scan status with counts and any errors
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the scan fails.
pub async fn scan_directory( pub async fn scan_directory(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -140,6 +146,10 @@ pub async fn scan_directory(
/// # Returns /// # Returns
/// ///
/// Scan status with counts and any errors /// Scan status with counts and any errors
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the scan fails.
pub async fn scan_directory_incremental( pub async fn scan_directory_incremental(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -165,6 +175,10 @@ pub async fn scan_directory_incremental(
/// # Returns /// # Returns
/// ///
/// Scan status with counts and any errors /// Scan status with counts and any errors
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the scan fails.
pub async fn scan_directory_with_progress( pub async fn scan_directory_with_progress(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -182,7 +196,11 @@ pub async fn scan_directory_with_progress(
} }
/// Scan a directory with full options including progress tracking and /// Scan a directory with full options including progress tracking and
/// incremental mode /// incremental mode.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the scan fails.
pub async fn scan_directory_with_options( pub async fn scan_directory_with_options(
storage: &DynStorageBackend, storage: &DynStorageBackend,
dir: &Path, dir: &Path,
@ -276,6 +294,10 @@ pub async fn scan_directory_with_options(
/// # Returns /// # Returns
/// ///
/// Status for each root directory /// Status for each root directory
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
pub async fn scan_all_roots( pub async fn scan_all_roots(
storage: &DynStorageBackend, storage: &DynStorageBackend,
ignore_patterns: &[String], ignore_patterns: &[String],
@ -299,6 +321,10 @@ pub async fn scan_all_roots(
/// # Returns /// # Returns
/// ///
/// Status for each root directory /// Status for each root directory
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
pub async fn scan_all_roots_incremental( pub async fn scan_all_roots_incremental(
storage: &DynStorageBackend, storage: &DynStorageBackend,
ignore_patterns: &[String], ignore_patterns: &[String],
@ -321,6 +347,10 @@ pub async fn scan_all_roots_incremental(
/// # Returns /// # Returns
/// ///
/// Status for each root directory /// Status for each root directory
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
pub async fn scan_all_roots_with_progress( pub async fn scan_all_roots_with_progress(
storage: &DynStorageBackend, storage: &DynStorageBackend,
ignore_patterns: &[String], ignore_patterns: &[String],
@ -347,6 +377,10 @@ pub async fn scan_all_roots_with_progress(
/// # Returns /// # Returns
/// ///
/// Status for each root directory /// Status for each root directory
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if listing roots or scanning fails.
pub async fn scan_all_roots_with_options( pub async fn scan_all_roots_with_options(
storage: &DynStorageBackend, storage: &DynStorageBackend,
ignore_patterns: &[String], ignore_patterns: &[String],
@ -391,6 +425,11 @@ pub struct FileWatcher {
impl FileWatcher { impl FileWatcher {
/// Creates a new file watcher for the given directories. /// Creates a new file watcher for the given directories.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if no filesystem watcher could be
/// created.
pub fn new(dirs: &[PathBuf]) -> Result<Self> { pub fn new(dirs: &[PathBuf]) -> Result<Self> {
let (tx, rx) = mpsc::channel(1024); let (tx, rx) = mpsc::channel(1024);
@ -419,7 +458,7 @@ impl FileWatcher {
dirs: &[PathBuf], dirs: &[PathBuf],
tx: mpsc::Sender<PathBuf>, tx: mpsc::Sender<PathBuf>,
) -> std::result::Result<Box<dyn Watcher + Send>, notify::Error> { ) -> std::result::Result<Box<dyn Watcher + Send>, notify::Error> {
let tx_clone = tx.clone(); let tx_clone = tx;
let mut watcher = notify::recommended_watcher( let mut watcher = notify::recommended_watcher(
move |res: notify::Result<notify::Event>| { move |res: notify::Result<notify::Event>| {
if let Ok(event) = res { if let Ok(event) = res {
@ -444,7 +483,7 @@ impl FileWatcher {
dirs: &[PathBuf], dirs: &[PathBuf],
tx: mpsc::Sender<PathBuf>, tx: mpsc::Sender<PathBuf>,
) -> Result<Box<dyn Watcher + Send>> { ) -> Result<Box<dyn Watcher + Send>> {
let tx_clone = tx.clone(); let tx_clone = tx;
let poll_interval = std::time::Duration::from_secs(5); let poll_interval = std::time::Duration::from_secs(5);
let config = notify::Config::default().with_poll_interval(poll_interval); let config = notify::Config::default().with_poll_interval(poll_interval);
@ -479,6 +518,10 @@ impl FileWatcher {
} }
/// Watches directories and imports files on change. /// Watches directories and imports files on change.
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the watcher cannot be started.
pub async fn watch_and_import( pub async fn watch_and_import(
storage: DynStorageBackend, storage: DynStorageBackend,
dirs: Vec<PathBuf>, dirs: Vec<PathBuf>,

View file

@ -29,12 +29,14 @@ pub enum Schedule {
} }
impl Schedule { impl Schedule {
#[must_use]
pub fn next_run(&self, from: DateTime<Utc>) -> DateTime<Utc> { pub fn next_run(&self, from: DateTime<Utc>) -> DateTime<Utc> {
match self { match self {
Schedule::Interval { secs } => { Self::Interval { secs } => {
from + chrono::Duration::seconds(*secs as i64) from
+ chrono::Duration::seconds(i64::try_from(*secs).unwrap_or(i64::MAX))
}, },
Schedule::Daily { hour, minute } => { Self::Daily { hour, minute } => {
let today = from let today = from
.date_naive() .date_naive()
.and_hms_opt(*hour, *minute, 0) .and_hms_opt(*hour, *minute, 0)
@ -46,26 +48,26 @@ impl Schedule {
today_utc + chrono::Duration::days(1) today_utc + chrono::Duration::days(1)
} }
}, },
Schedule::Weekly { day, hour, minute } => { Self::Weekly { day, hour, minute } => {
let current_day = from.weekday().num_days_from_monday(); let current_day = from.weekday().num_days_from_monday();
let target_day = *day; let target_day = *day;
let days_ahead = if target_day > current_day { let days_ahead = match target_day.cmp(&current_day) {
target_day - current_day std::cmp::Ordering::Greater => target_day - current_day,
} else if target_day < current_day { std::cmp::Ordering::Less => 7 - (current_day - target_day),
7 - (current_day - target_day) std::cmp::Ordering::Equal => {
} else { let today = from
let today = from .date_naive()
.date_naive() .and_hms_opt(*hour, *minute, 0)
.and_hms_opt(*hour, *minute, 0) .unwrap_or_default()
.unwrap_or_default() .and_utc();
.and_utc(); if today > from {
if today > from { return today;
return today; }
} 7
7 },
}; };
let target_date = let target_date =
from.date_naive() + chrono::Duration::days(days_ahead as i64); from.date_naive() + chrono::Duration::days(i64::from(days_ahead));
target_date target_date
.and_hms_opt(*hour, *minute, 0) .and_hms_opt(*hour, *minute, 0)
.unwrap_or_default() .unwrap_or_default()
@ -74,21 +76,22 @@ impl Schedule {
} }
} }
#[must_use]
pub fn display_string(&self) -> String { pub fn display_string(&self) -> String {
match self { match self {
Schedule::Interval { secs } => { Self::Interval { secs } => {
if *secs >= 3600 { if *secs >= 3600 {
format!("Every {}h", secs / 3600) format!("Every {}h", secs / 3600)
} else if *secs >= 60 { } else if *secs >= 60 {
format!("Every {}m", secs / 60) format!("Every {}m", secs / 60)
} else { } else {
format!("Every {}s", secs) format!("Every {secs}s")
} }
}, },
Schedule::Daily { hour, minute } => { Self::Daily { hour, minute } => {
format!("Daily {hour:02}:{minute:02}") format!("Daily {hour:02}:{minute:02}")
}, },
Schedule::Weekly { day, hour, minute } => { Self::Weekly { day, hour, minute } => {
let day_name = match day { let day_name = match day {
0 => "Mon", 0 => "Mon",
1 => "Tue", 1 => "Tue",
@ -312,13 +315,17 @@ impl TaskScheduler {
/// Run a task immediately. Uses a single write lock to avoid TOCTOU races. /// Run a task immediately. Uses a single write lock to avoid TOCTOU races.
pub async fn run_now(&self, id: &str) -> Option<String> { pub async fn run_now(&self, id: &str) -> Option<String> {
let result = { let kind = {
let tasks = self.tasks.read().await;
tasks.iter().find(|t| t.id == id)?.kind.clone()
};
// Submit the job (cheap: sends to mpsc channel)
let job_id = self.job_queue.submit(kind).await;
{
let mut tasks = self.tasks.write().await; let mut tasks = self.tasks.write().await;
let task = tasks.iter_mut().find(|t| t.id == id)?; let task = tasks.iter_mut().find(|t| t.id == id)?;
// Submit the job (cheap: sends to mpsc channel)
let job_id = self.job_queue.submit(task.kind.clone()).await;
task.last_run = Some(Utc::now()); task.last_run = Some(Utc::now());
task.last_status = Some("running".to_string()); task.last_status = Some("running".to_string());
task.running = true; task.running = true;
@ -326,13 +333,11 @@ impl TaskScheduler {
if task.enabled { if task.enabled {
task.next_run = Some(task.schedule.next_run(Utc::now())); task.next_run = Some(task.schedule.next_run(Utc::now()));
} }
drop(tasks);
Some(job_id.to_string())
};
if result.is_some() {
self.persist_task_state().await;
} }
result
self.persist_task_state().await;
Some(job_id.to_string())
} }
/// Main scheduler loop. Uses a two-phase approach per tick to avoid /// Main scheduler loop. Uses a two-phase approach per tick to avoid
@ -344,7 +349,7 @@ impl TaskScheduler {
loop { loop {
tokio::select! { tokio::select! {
_ = interval.tick() => {} _ = interval.tick() => {}
_ = self.cancel.cancelled() => { () = self.cancel.cancelled() => {
tracing::info!("scheduler shutting down"); tracing::info!("scheduler shutting down");
return; return;
} }
@ -514,7 +519,7 @@ mod tests {
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "test"); assert_eq!(deserialized.id, "test");
assert_eq!(deserialized.enabled, true); assert!(deserialized.enabled);
// running defaults to false on deserialization (skip_serializing) // running defaults to false on deserialization (skip_serializing)
assert!(!deserialized.running); assert!(!deserialized.running);
// last_job_id is skipped entirely // last_job_id is skipped entirely
@ -603,7 +608,8 @@ mod tests {
async fn test_default_tasks_contain_trash_purge() { async fn test_default_tasks_contain_trash_purge() {
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let config = Arc::new(RwLock::new(Config::default())); let config = Arc::new(RwLock::new(Config::default()));
let job_queue = JobQueue::new(1, |_, _, _, _| tokio::spawn(async move {})); let job_queue =
JobQueue::new(1, 0, |_, _, _, _| tokio::spawn(async move {}));
let scheduler = TaskScheduler::new(job_queue, cancel, config, None); let scheduler = TaskScheduler::new(job_queue, cancel, config, None);
let tasks = scheduler.list_tasks().await; let tasks = scheduler.list_tasks().await;
@ -644,7 +650,7 @@ mod tests {
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "trash_purge"); assert_eq!(deserialized.id, "trash_purge");
assert_eq!(deserialized.enabled, true); assert!(deserialized.enabled);
assert!(!deserialized.running); assert!(!deserialized.running);
assert!(deserialized.last_job_id.is_none()); assert!(deserialized.last_job_id.is_none());
} }
@ -699,7 +705,7 @@ mod tests {
let json = serde_json::to_string(&task).unwrap(); let json = serde_json::to_string(&task).unwrap();
let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.running, false); assert!(!deserialized.running);
assert!(deserialized.last_job_id.is_none()); assert!(deserialized.last_job_id.is_none());
} }

View file

@ -14,9 +14,9 @@ pub enum SearchQuery {
field: String, field: String,
value: String, value: String,
}, },
And(Vec<SearchQuery>), And(Vec<Self>),
Or(Vec<SearchQuery>), Or(Vec<Self>),
Not(Box<SearchQuery>), Not(Box<Self>),
Prefix(String), Prefix(String),
Fuzzy(String), Fuzzy(String),
TypeFilter(String), TypeFilter(String),
@ -149,18 +149,13 @@ fn parse_date_value(s: &str) -> Option<DateValue> {
/// Returns `None` if the input is invalid or if the value would overflow. /// Returns `None` if the input is invalid or if the value would overflow.
fn parse_size_value(s: &str) -> Option<i64> { fn parse_size_value(s: &str) -> Option<i64> {
let s = s.to_uppercase(); let s = s.to_uppercase();
let (num_str, multiplier): (&str, i64) = if let Some(n) = s.strip_suffix("GB") let (num_str, multiplier): (&str, i64) = s
{ .strip_suffix("GB")
(n, 1024 * 1024 * 1024) .map(|n| (n, 1024 * 1024 * 1024_i64))
} else if let Some(n) = s.strip_suffix("MB") { .or_else(|| s.strip_suffix("MB").map(|n| (n, 1024 * 1024)))
(n, 1024 * 1024) .or_else(|| s.strip_suffix("KB").map(|n| (n, 1024)))
} else if let Some(n) = s.strip_suffix("KB") { .or_else(|| s.strip_suffix('B').map(|n| (n, 1)))
(n, 1024) .unwrap_or((s.as_str(), 1));
} else if let Some(n) = s.strip_suffix('B') {
(n, 1)
} else {
(s.as_str(), 1)
};
let num: i64 = num_str.parse().ok()?; let num: i64 = num_str.parse().ok()?;
num.checked_mul(multiplier) num.checked_mul(multiplier)

View file

@ -21,22 +21,32 @@ pub struct ChunkedUploadManager {
impl ChunkedUploadManager { impl ChunkedUploadManager {
/// Create a new chunked upload manager. /// Create a new chunked upload manager.
pub fn new(temp_dir: PathBuf) -> Self { #[must_use]
pub const fn new(temp_dir: PathBuf) -> Self {
Self { temp_dir } Self { temp_dir }
} }
/// Initialize the temp directory. /// Initialize the temp directory.
///
/// # Errors
///
/// Returns an error if the directory cannot be created.
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
fs::create_dir_all(&self.temp_dir).await?; fs::create_dir_all(&self.temp_dir).await?;
Ok(()) Ok(())
} }
/// Get the temp file path for an upload session. /// Get the temp file path for an upload session.
#[must_use]
pub fn temp_path(&self, session_id: Uuid) -> PathBuf { pub fn temp_path(&self, session_id: Uuid) -> PathBuf {
self.temp_dir.join(format!("{}.upload", session_id)) self.temp_dir.join(format!("{session_id}.upload"))
} }
/// Create the temp file for a new upload session. /// Create the temp file for a new upload session.
///
/// # Errors
///
/// Returns an error if the file cannot be created or sized.
pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> {
let path = self.temp_path(session.id); let path = self.temp_path(session.id);
@ -54,6 +64,11 @@ impl ChunkedUploadManager {
} }
/// Write a chunk to the temp file. /// Write a chunk to the temp file.
///
/// # Errors
///
/// Returns an error if the session file is not found, the chunk index is out
/// of range, the chunk size is wrong, or the write fails.
pub async fn write_chunk( pub async fn write_chunk(
&self, &self,
session: &UploadSession, session: &UploadSession,
@ -128,6 +143,11 @@ impl ChunkedUploadManager {
/// 1. All chunks are received /// 1. All chunks are received
/// 2. File size matches expected /// 2. File size matches expected
/// 3. Content hash matches expected /// 3. Content hash matches expected
///
/// # Errors
///
/// Returns an error if chunks are missing, the file size does not match, the
/// hash does not match, or the file metadata cannot be read.
pub async fn finalize( pub async fn finalize(
&self, &self,
session: &UploadSession, session: &UploadSession,
@ -147,12 +167,11 @@ impl ChunkedUploadManager {
// Verify chunk indices // Verify chunk indices
let mut indices: Vec<u64> = let mut indices: Vec<u64> =
received_chunks.iter().map(|c| c.chunk_index).collect(); received_chunks.iter().map(|c| c.chunk_index).collect();
indices.sort(); indices.sort_unstable();
for (i, idx) in indices.iter().enumerate() { for (i, idx) in indices.iter().enumerate() {
if *idx != i as u64 { if *idx != i as u64 {
return Err(PinakesError::InvalidData(format!( return Err(PinakesError::InvalidData(format!(
"chunk {} missing or out of order", "chunk {i} missing or out of order"
i
))); )));
} }
} }
@ -187,6 +206,10 @@ impl ChunkedUploadManager {
} }
/// Cancel an upload and clean up temp file. /// Cancel an upload and clean up temp file.
///
/// # Errors
///
/// Returns an error if the temp file cannot be removed.
pub async fn cancel(&self, session_id: Uuid) -> Result<()> { pub async fn cancel(&self, session_id: Uuid) -> Result<()> {
let path = self.temp_path(session_id); let path = self.temp_path(session_id);
if path.exists() { if path.exists() {
@ -197,6 +220,10 @@ impl ChunkedUploadManager {
} }
/// Clean up expired temp files. /// Clean up expired temp files.
///
/// # Errors
///
/// Returns an error if the temp directory cannot be read.
pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result<u64> { pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result<u64> {
let mut count = 0u64; let mut count = 0u64;
let max_age = std::time::Duration::from_secs(max_age_hours * 3600); let max_age = std::time::Duration::from_secs(max_age_hours * 3600);
@ -204,7 +231,7 @@ impl ChunkedUploadManager {
let mut entries = fs::read_dir(&self.temp_dir).await?; let mut entries = fs::read_dir(&self.temp_dir).await?;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let path = entry.path(); let path = entry.path();
if path.extension().map(|e| e == "upload").unwrap_or(false) if path.extension().is_some_and(|e| e == "upload")
&& let Ok(metadata) = fs::metadata(&path).await && let Ok(metadata) = fs::metadata(&path).await
&& let Ok(modified) = metadata.modified() && let Ok(modified) = metadata.modified()
{ {
@ -267,7 +294,7 @@ mod tests {
expected_hash: ContentHash::new(hash.clone()), expected_hash: ContentHash::new(hash.clone()),
expected_size: data.len() as u64, expected_size: data.len() as u64,
chunk_size, chunk_size,
chunk_count: (data.len() as u64 + chunk_size - 1) / chunk_size, chunk_count: (data.len() as u64).div_ceil(chunk_size),
status: UploadStatus::InProgress, status: UploadStatus::InProgress,
created_at: Utc::now(), created_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(24), expires_at: Utc::now() + chrono::Duration::hours(24),

View file

@ -4,6 +4,7 @@ use super::DeviceSyncState;
use crate::config::ConflictResolution; use crate::config::ConflictResolution;
/// Detect if there's a conflict between local and server state. /// Detect if there's a conflict between local and server state.
#[must_use]
pub fn detect_conflict(state: &DeviceSyncState) -> Option<ConflictInfo> { pub fn detect_conflict(state: &DeviceSyncState) -> Option<ConflictInfo> {
// If either side has no hash, no conflict possible // If either side has no hash, no conflict possible
let local_hash = state.local_hash.as_ref()?; let local_hash = state.local_hash.as_ref()?;
@ -48,6 +49,7 @@ pub enum ConflictOutcome {
} }
/// Resolve a conflict based on the configured strategy. /// Resolve a conflict based on the configured strategy.
#[must_use]
pub fn resolve_conflict( pub fn resolve_conflict(
conflict: &ConflictInfo, conflict: &ConflictInfo,
resolution: ConflictResolution, resolution: ConflictResolution,
@ -67,20 +69,21 @@ pub fn resolve_conflict(
} }
/// Generate a new path for the conflicting local file. /// Generate a new path for the conflicting local file.
/// Format: filename.conflict-<short_hash>.ext /// Format: filename.conflict-<`short_hash>.ext`
fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { fn generate_conflict_path(original_path: &str, local_hash: &str) -> String {
let short_hash = &local_hash[..8.min(local_hash.len())]; let short_hash = &local_hash[..8.min(local_hash.len())];
if let Some((base, ext)) = original_path.rsplit_once('.') { if let Some((base, ext)) = original_path.rsplit_once('.') {
format!("{}.conflict-{}.{}", base, short_hash, ext) format!("{base}.conflict-{short_hash}.{ext}")
} else { } else {
format!("{}.conflict-{}", original_path, short_hash) format!("{original_path}.conflict-{short_hash}")
} }
} }
/// Automatic conflict resolution based on modification times. /// Automatic conflict resolution based on modification times.
/// Useful when ConflictResolution is set to a time-based strategy. /// Useful when `ConflictResolution` is set to a time-based strategy.
pub fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { #[must_use]
pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome {
match (conflict.local_mtime, conflict.server_mtime) { match (conflict.local_mtime, conflict.server_mtime) {
(Some(local), Some(server)) => { (Some(local), Some(server)) => {
if local > server { if local > server {

View file

@ -17,6 +17,7 @@ use crate::{
pub struct DeviceId(pub Uuid); pub struct DeviceId(pub Uuid);
impl DeviceId { impl DeviceId {
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self(Uuid::now_v7()) Self(Uuid::now_v7())
} }
@ -70,7 +71,7 @@ impl std::str::FromStr for DeviceType {
"tablet" => Ok(Self::Tablet), "tablet" => Ok(Self::Tablet),
"server" => Ok(Self::Server), "server" => Ok(Self::Server),
"other" => Ok(Self::Other), "other" => Ok(Self::Other),
_ => Err(format!("unknown device type: {}", s)), _ => Err(format!("unknown device type: {s}")),
} }
} }
} }
@ -93,6 +94,7 @@ pub struct SyncDevice {
} }
impl SyncDevice { impl SyncDevice {
#[must_use]
pub fn new( pub fn new(
user_id: UserId, user_id: UserId,
name: String, name: String,
@ -150,7 +152,7 @@ impl std::str::FromStr for SyncChangeType {
"deleted" => Ok(Self::Deleted), "deleted" => Ok(Self::Deleted),
"moved" => Ok(Self::Moved), "moved" => Ok(Self::Moved),
"metadata_updated" => Ok(Self::MetadataUpdated), "metadata_updated" => Ok(Self::MetadataUpdated),
_ => Err(format!("unknown sync change type: {}", s)), _ => Err(format!("unknown sync change type: {s}")),
} }
} }
} }
@ -171,6 +173,7 @@ pub struct SyncLogEntry {
} }
impl SyncLogEntry { impl SyncLogEntry {
#[must_use]
pub fn new( pub fn new(
change_type: SyncChangeType, change_type: SyncChangeType,
path: String, path: String,
@ -225,7 +228,7 @@ impl std::str::FromStr for FileSyncStatus {
"pending_download" => Ok(Self::PendingDownload), "pending_download" => Ok(Self::PendingDownload),
"conflict" => Ok(Self::Conflict), "conflict" => Ok(Self::Conflict),
"deleted" => Ok(Self::Deleted), "deleted" => Ok(Self::Deleted),
_ => Err(format!("unknown file sync status: {}", s)), _ => Err(format!("unknown file sync status: {s}")),
} }
} }
} }
@ -260,6 +263,7 @@ pub struct SyncConflict {
} }
impl SyncConflict { impl SyncConflict {
#[must_use]
pub fn new( pub fn new(
device_id: DeviceId, device_id: DeviceId,
path: String, path: String,
@ -319,7 +323,7 @@ impl std::str::FromStr for UploadStatus {
"failed" => Ok(Self::Failed), "failed" => Ok(Self::Failed),
"expired" => Ok(Self::Expired), "expired" => Ok(Self::Expired),
"cancelled" => Ok(Self::Cancelled), "cancelled" => Ok(Self::Cancelled),
_ => Err(format!("unknown upload status: {}", s)), _ => Err(format!("unknown upload status: {s}")),
} }
} }
} }
@ -341,6 +345,7 @@ pub struct UploadSession {
} }
impl UploadSession { impl UploadSession {
#[must_use]
pub fn new( pub fn new(
device_id: DeviceId, device_id: DeviceId,
target_path: String, target_path: String,

View file

@ -90,6 +90,10 @@ pub struct AckRequest {
} }
/// Get changes since a cursor position. /// Get changes since a cursor position.
///
/// # Errors
///
/// Returns an error if the storage query fails.
pub async fn get_changes( pub async fn get_changes(
storage: &DynStorageBackend, storage: &DynStorageBackend,
cursor: i64, cursor: i64,
@ -101,7 +105,7 @@ pub async fn get_changes(
let has_more = changes.len() > limit as usize; let has_more = changes.len() > limit as usize;
let changes: Vec<_> = changes.into_iter().take(limit as usize).collect(); let changes: Vec<_> = changes.into_iter().take(limit as usize).collect();
let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor); let new_cursor = changes.last().map_or(cursor, |c| c.sequence);
Ok(ChangesResponse { Ok(ChangesResponse {
changes, changes,
@ -111,6 +115,10 @@ pub async fn get_changes(
} }
/// Record a change in the sync log. /// Record a change in the sync log.
///
/// # Errors
///
/// Returns an error if the storage record operation fails.
pub async fn record_change( pub async fn record_change(
storage: &DynStorageBackend, storage: &DynStorageBackend,
change_type: SyncChangeType, change_type: SyncChangeType,
@ -138,6 +146,10 @@ pub async fn record_change(
} }
/// Update device cursor after processing changes. /// Update device cursor after processing changes.
///
/// # Errors
///
/// Returns an error if the device lookup or update fails.
pub async fn update_device_cursor( pub async fn update_device_cursor(
storage: &DynStorageBackend, storage: &DynStorageBackend,
device_id: DeviceId, device_id: DeviceId,
@ -152,6 +164,10 @@ pub async fn update_device_cursor(
} }
/// Mark a file as synced for a device. /// Mark a file as synced for a device.
///
/// # Errors
///
/// Returns an error if the storage upsert operation fails.
pub async fn mark_synced( pub async fn mark_synced(
storage: &DynStorageBackend, storage: &DynStorageBackend,
device_id: DeviceId, device_id: DeviceId,
@ -176,6 +192,10 @@ pub async fn mark_synced(
} }
/// Mark a file as pending download for a device. /// Mark a file as pending download for a device.
///
/// # Errors
///
/// Returns an error if the storage lookup or upsert operation fails.
pub async fn mark_pending_download( pub async fn mark_pending_download(
storage: &DynStorageBackend, storage: &DynStorageBackend,
device_id: DeviceId, device_id: DeviceId,
@ -211,6 +231,7 @@ pub async fn mark_pending_download(
} }
/// Generate a device token using UUIDs for randomness. /// Generate a device token using UUIDs for randomness.
#[must_use]
pub fn generate_device_token() -> String { pub fn generate_device_token() -> String {
// Concatenate two UUIDs for 256 bits of randomness // Concatenate two UUIDs for 256 bits of randomness
let uuid1 = uuid::Uuid::new_v4(); let uuid1 = uuid::Uuid::new_v4();
@ -219,6 +240,7 @@ pub fn generate_device_token() -> String {
} }
/// Hash a device token for storage. /// Hash a device token for storage.
#[must_use]
pub fn hash_device_token(token: &str) -> String { pub fn hash_device_token(token: &str) -> String {
blake3::hash(token.as_bytes()).to_hex().to_string() blake3::hash(token.as_bytes()).to_hex().to_string()
} }

View file

@ -17,6 +17,10 @@ use crate::{
/// # Returns /// # Returns
/// ///
/// The created tag /// The created tag
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn create_tag( pub async fn create_tag(
storage: &DynStorageBackend, storage: &DynStorageBackend,
name: &str, name: &str,
@ -36,6 +40,10 @@ pub async fn create_tag(
/// # Returns /// # Returns
/// ///
/// `Ok(())` on success /// `Ok(())` on success
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn tag_media( pub async fn tag_media(
storage: &DynStorageBackend, storage: &DynStorageBackend,
media_id: MediaId, media_id: MediaId,
@ -62,6 +70,10 @@ pub async fn tag_media(
/// # Returns /// # Returns
/// ///
/// `Ok(())` on success /// `Ok(())` on success
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn untag_media( pub async fn untag_media(
storage: &DynStorageBackend, storage: &DynStorageBackend,
media_id: MediaId, media_id: MediaId,
@ -87,6 +99,10 @@ pub async fn untag_media(
/// # Returns /// # Returns
/// ///
/// List of child tags /// List of child tags
///
/// # Errors
///
/// Returns [`crate::error::PinakesError`] if the storage operation fails.
pub async fn get_tag_tree( pub async fn get_tag_tree(
storage: &DynStorageBackend, storage: &DynStorageBackend,
tag_id: Uuid, tag_id: Uuid,

View file

@ -16,7 +16,7 @@ use crate::{
struct TempFileGuard(PathBuf); struct TempFileGuard(PathBuf);
impl TempFileGuard { impl TempFileGuard {
fn new(path: PathBuf) -> Self { const fn new(path: PathBuf) -> Self {
Self(path) Self(path)
} }
@ -35,10 +35,14 @@ impl Drop for TempFileGuard {
/// ///
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via /// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via
/// pdftoppm), and EPUBs (via cover image extraction). /// pdftoppm), and EPUBs (via cover image extraction).
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_thumbnail( pub fn generate_thumbnail(
media_id: MediaId, media_id: MediaId,
source_path: &Path, source_path: &Path,
media_type: MediaType, media_type: &MediaType,
thumbnail_dir: &Path, thumbnail_dir: &Path,
) -> Result<Option<PathBuf>> { ) -> Result<Option<PathBuf>> {
generate_thumbnail_with_config( generate_thumbnail_with_config(
@ -50,21 +54,26 @@ pub fn generate_thumbnail(
) )
} }
/// Generate a thumbnail with custom configuration.
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_thumbnail_with_config( pub fn generate_thumbnail_with_config(
media_id: MediaId, media_id: MediaId,
source_path: &Path, source_path: &Path,
media_type: MediaType, media_type: &MediaType,
thumbnail_dir: &Path, thumbnail_dir: &Path,
config: &ThumbnailConfig, config: &ThumbnailConfig,
) -> Result<Option<PathBuf>> { ) -> Result<Option<PathBuf>> {
std::fs::create_dir_all(thumbnail_dir)?; std::fs::create_dir_all(thumbnail_dir)?;
let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id)); let thumb_path = thumbnail_dir.join(format!("{media_id}.jpg"));
let result = match media_type.category() { let result = match media_type.category() {
MediaCategory::Image => { MediaCategory::Image => {
if media_type.is_raw() { if media_type.is_raw() {
generate_raw_thumbnail(source_path, &thumb_path, config) generate_raw_thumbnail(source_path, &thumb_path, config)
} else if media_type == MediaType::Builtin(BuiltinMediaType::Heic) { } else if *media_type == MediaType::Builtin(BuiltinMediaType::Heic) {
generate_heic_thumbnail(source_path, &thumb_path, config) generate_heic_thumbnail(source_path, &thumb_path, config)
} else { } else {
generate_image_thumbnail(source_path, &thumb_path, config) generate_image_thumbnail(source_path, &thumb_path, config)
@ -151,8 +160,7 @@ fn generate_video_thumbnail(
if !status.success() { if !status.success() {
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"ffmpeg exited with status {}", "ffmpeg exited with status {status}"
status
))); )));
} }
@ -180,8 +188,7 @@ fn generate_pdf_thumbnail(
if !status.success() { if !status.success() {
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"pdftoppm exited with status {}", "pdftoppm exited with status {status}"
status
))); )));
} }
@ -272,8 +279,7 @@ fn generate_raw_thumbnail(
if !status.success() { if !status.success() {
// Guard drops and cleans up temp_ppm // Guard drops and cleans up temp_ppm
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"dcraw exited with status {}", "dcraw exited with status {status}"
status
))); )));
} }
@ -320,8 +326,7 @@ fn generate_heic_thumbnail(
if !status.success() { if !status.success() {
// Guard drops and cleans up temp_jpg // Guard drops and cleans up temp_jpg
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"heif-convert exited with status {}", "heif-convert exited with status {status}"
status
))); )));
} }
@ -357,26 +362,32 @@ pub enum CoverSize {
} }
impl CoverSize { impl CoverSize {
pub fn dimensions(&self) -> Option<(u32, u32)> { #[must_use]
pub const fn dimensions(&self) -> Option<(u32, u32)> {
match self { match self {
CoverSize::Tiny => Some((64, 64)), Self::Tiny => Some((64, 64)),
CoverSize::Grid => Some((320, 320)), Self::Grid => Some((320, 320)),
CoverSize::Preview => Some((1024, 1024)), Self::Preview => Some((1024, 1024)),
CoverSize::Original => None, // No resizing Self::Original => None, // No resizing
} }
} }
pub fn filename(&self) -> &'static str { #[must_use]
pub const fn filename(&self) -> &'static str {
match self { match self {
CoverSize::Tiny => "tiny.jpg", Self::Tiny => "tiny.jpg",
CoverSize::Grid => "grid.jpg", Self::Grid => "grid.jpg",
CoverSize::Preview => "preview.jpg", Self::Preview => "preview.jpg",
CoverSize::Original => "original.jpg", Self::Original => "original.jpg",
} }
} }
} }
/// Generate multi-resolution covers for a book /// Generate multi-resolution covers for a book.
///
/// # Errors
///
/// Returns [`PinakesError`] if the cover image cannot be decoded or encoded.
pub fn generate_book_covers( pub fn generate_book_covers(
media_id: MediaId, media_id: MediaId,
source_image: &[u8], source_image: &[u8],
@ -401,26 +412,23 @@ pub fn generate_book_covers(
] { ] {
let cover_path = media_cover_dir.join(size.filename()); let cover_path = media_cover_dir.join(size.filename());
match size.dimensions() { if let Some((width, height)) = size.dimensions() {
Some((width, height)) => { // Generate thumbnail
// Generate thumbnail let thumb = img.thumbnail(width, height);
let thumb = img.thumbnail(width, height); let mut output = std::fs::File::create(&cover_path)?;
let mut output = std::fs::File::create(&cover_path)?; let encoder =
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90);
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90); thumb.write_with_encoder(encoder).map_err(|e| {
thumb.write_with_encoder(encoder).map_err(|e| { PinakesError::MetadataExtraction(format!("cover encode: {e}"))
PinakesError::MetadataExtraction(format!("cover encode: {e}")) })?;
})?; } else {
}, // Save original
None => { let mut output = std::fs::File::create(&cover_path)?;
// Save original let encoder =
let mut output = std::fs::File::create(&cover_path)?; image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
let encoder = img.write_with_encoder(encoder).map_err(|e| {
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); PinakesError::MetadataExtraction(format!("cover encode: {e}"))
img.write_with_encoder(encoder).map_err(|e| { })?;
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
})?;
},
} }
results.push((size, cover_path)); results.push((size, cover_path));
@ -429,7 +437,11 @@ pub fn generate_book_covers(
Ok(results) Ok(results)
} }
/// Extract full-size cover from an EPUB file /// Extract full-size cover from an EPUB file.
///
/// # Errors
///
/// Returns [`PinakesError`] if the EPUB cannot be opened or read.
pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> { pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
let mut doc = epub::doc::EpubDoc::new(epub_path) let mut doc = epub::doc::EpubDoc::new(epub_path)
.map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?; .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?;
@ -459,7 +471,11 @@ pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
Ok(None) Ok(None)
} }
/// Extract full-size cover from a PDF file (first page) /// Extract full-size cover from a PDF file (first page).
///
/// # Errors
///
/// Returns [`PinakesError`] if pdftoppm cannot be executed or fails.
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> { pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let temp_prefix = let temp_prefix =
@ -476,8 +492,7 @@ pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
if !status.success() { if !status.success() {
return Err(PinakesError::MetadataExtraction(format!( return Err(PinakesError::MetadataExtraction(format!(
"pdftoppm exited with status {}", "pdftoppm exited with status {status}"
status
))); )));
} }
@ -497,11 +512,13 @@ pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
} }
/// Returns the default covers directory under the data dir /// Returns the default covers directory under the data dir
#[must_use]
pub fn default_covers_dir() -> PathBuf { pub fn default_covers_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("covers") crate::config::Config::default_data_dir().join("covers")
} }
/// Returns the default thumbnail directory under the data dir. /// Returns the default thumbnail directory under the data dir.
#[must_use]
pub fn default_thumbnail_dir() -> PathBuf { pub fn default_thumbnail_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("thumbnails") crate::config::Config::default_data_dir().join("thumbnails")
} }
@ -519,30 +536,37 @@ pub enum ThumbnailSize {
impl ThumbnailSize { impl ThumbnailSize {
/// Get the pixel size for this thumbnail variant /// Get the pixel size for this thumbnail variant
pub fn pixels(&self) -> u32 { #[must_use]
pub const fn pixels(&self) -> u32 {
match self { match self {
ThumbnailSize::Tiny => 64, Self::Tiny => 64,
ThumbnailSize::Grid => 320, Self::Grid => 320,
ThumbnailSize::Preview => 1024, Self::Preview => 1024,
} }
} }
/// Get the subdirectory name for this size /// Get the subdirectory name for this size
pub fn subdir_name(&self) -> &'static str { #[must_use]
pub const fn subdir_name(&self) -> &'static str {
match self { match self {
ThumbnailSize::Tiny => "tiny", Self::Tiny => "tiny",
ThumbnailSize::Grid => "grid", Self::Grid => "grid",
ThumbnailSize::Preview => "preview", Self::Preview => "preview",
} }
} }
} }
/// Generate all thumbnail sizes for a media file /// Generate all thumbnail sizes for a media file.
/// Returns paths to the generated thumbnails (tiny, grid, preview) ///
/// Returns paths to the generated thumbnails (tiny, grid, preview).
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_all_thumbnail_sizes( pub fn generate_all_thumbnail_sizes(
media_id: MediaId, media_id: MediaId,
source_path: &Path, source_path: &Path,
media_type: MediaType, media_type: &MediaType,
thumbnail_base_dir: &Path, thumbnail_base_dir: &Path,
) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> { ) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> {
let sizes = [ let sizes = [
@ -564,7 +588,7 @@ pub fn generate_all_thumbnail_sizes(
let result = generate_thumbnail_with_config( let result = generate_thumbnail_with_config(
media_id, media_id,
source_path, source_path,
media_type.clone(), media_type,
&size_dir, &size_dir,
&config, &config,
)?; )?;

View file

@ -1,4 +1,4 @@
//! Transcoding service for media files using FFmpeg. //! Transcoding service for media files using `FFmpeg`.
use std::{ use std::{
collections::HashMap, collections::HashMap,
@ -33,7 +33,7 @@ pub struct TranscodeSession {
/// Duration of the source media in seconds, used for progress calculation. /// Duration of the source media in seconds, used for progress calculation.
#[serde(default)] #[serde(default)]
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
/// Handle to cancel the child FFmpeg process. /// Handle to cancel the child `FFmpeg` process.
#[serde(skip)] #[serde(skip)]
pub child_cancel: Option<Arc<tokio::sync::Notify>>, pub child_cancel: Option<Arc<tokio::sync::Notify>>,
} }
@ -50,7 +50,8 @@ pub enum TranscodeStatus {
} }
impl TranscodeStatus { impl TranscodeStatus {
pub fn as_str(&self) -> &str { #[must_use]
pub const fn as_str(&self) -> &str {
match self { match self {
Self::Pending => "pending", Self::Pending => "pending",
Self::Transcoding => "transcoding", Self::Transcoding => "transcoding",
@ -81,6 +82,7 @@ impl TranscodeStatus {
} }
} }
#[must_use]
pub fn error_message(&self) -> Option<&str> { pub fn error_message(&self) -> Option<&str> {
match self { match self {
Self::Failed { error } => Some(error), Self::Failed { error } => Some(error),
@ -89,7 +91,7 @@ impl TranscodeStatus {
} }
} }
/// Service managing transcoding sessions and FFmpeg invocations. /// Service managing transcoding sessions and `FFmpeg` invocations.
pub struct TranscodeService { pub struct TranscodeService {
pub config: TranscodingConfig, pub config: TranscodingConfig,
pub sessions: Arc<RwLock<HashMap<Uuid, TranscodeSession>>>, pub sessions: Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
@ -97,6 +99,7 @@ pub struct TranscodeService {
} }
impl TranscodeService { impl TranscodeService {
#[must_use]
pub fn new(config: TranscodingConfig) -> Self { pub fn new(config: TranscodingConfig) -> Self {
let max_concurrent = config.max_concurrent.max(1); let max_concurrent = config.max_concurrent.max(1);
Self { Self {
@ -106,10 +109,12 @@ impl TranscodeService {
} }
} }
pub fn is_enabled(&self) -> bool { #[must_use]
pub const fn is_enabled(&self) -> bool {
self.config.enabled self.config.enabled
} }
#[must_use]
pub fn cache_dir(&self) -> PathBuf { pub fn cache_dir(&self) -> PathBuf {
self self
.config .config
@ -119,6 +124,11 @@ impl TranscodeService {
} }
/// Start a transcode job for a media item. /// Start a transcode job for a media item.
///
/// # Errors
///
/// Returns an error if the profile is not found, the session directory cannot
/// be created, or the session cannot be stored in the database.
pub async fn start_transcode( pub async fn start_transcode(
&self, &self,
media_id: MediaId, media_id: MediaId,
@ -135,8 +145,7 @@ impl TranscodeService {
.cloned() .cloned()
.ok_or_else(|| { .ok_or_else(|| {
crate::error::PinakesError::InvalidOperation(format!( crate::error::PinakesError::InvalidOperation(format!(
"unknown transcode profile: {}", "unknown transcode profile: {profile_name}"
profile_name
)) ))
})?; })?;
@ -144,13 +153,15 @@ impl TranscodeService {
let session_dir = self.cache_dir().join(session_id.to_string()); let session_dir = self.cache_dir().join(session_id.to_string());
tokio::fs::create_dir_all(&session_dir).await.map_err(|e| { tokio::fs::create_dir_all(&session_dir).await.map_err(|e| {
crate::error::PinakesError::InvalidOperation(format!( crate::error::PinakesError::InvalidOperation(format!(
"failed to create session directory: {}", "failed to create session directory: {e}"
e
)) ))
})?; })?;
let expires_at = Some( let expires_at = Some(
Utc::now() + chrono::Duration::hours(self.config.cache_ttl_hours as i64), Utc::now()
+ chrono::Duration::hours(
i64::try_from(self.config.cache_ttl_hours).unwrap_or(i64::MAX),
),
); );
let cancel_notify = Arc::new(tokio::sync::Notify::new()); let cancel_notify = Arc::new(tokio::sync::Notify::new());
@ -166,7 +177,7 @@ impl TranscodeService {
created_at: Utc::now(), created_at: Utc::now(),
expires_at, expires_at,
duration_secs, duration_secs,
child_cancel: Some(cancel_notify.clone()), child_cancel: Some(Arc::clone(&cancel_notify)),
}; };
// Store session in DB // Store session in DB
@ -179,12 +190,12 @@ impl TranscodeService {
} }
// Spawn the FFmpeg task // Spawn the FFmpeg task
let sessions = self.sessions.clone(); let sessions = Arc::clone(&self.sessions);
let semaphore = self.semaphore.clone(); let semaphore = Arc::clone(&self.semaphore);
let source = source_path.to_path_buf(); let source = source_path.to_path_buf();
let hw_accel = self.config.hardware_acceleration.clone(); let hw_accel = self.config.hardware_acceleration.clone();
let storage = storage.clone(); let storage = Arc::clone(storage);
let cancel = cancel_notify.clone(); let cancel = Arc::clone(&cancel_notify);
tokio::spawn(async move { tokio::spawn(async move {
// Acquire semaphore permit to limit concurrency // Acquire semaphore permit to limit concurrency
@ -192,12 +203,14 @@ impl TranscodeService {
Ok(permit) => permit, Ok(permit) => permit,
Err(e) => { Err(e) => {
tracing::error!("failed to acquire transcode semaphore: {}", e); tracing::error!("failed to acquire transcode semaphore: {}", e);
let error_msg = format!("semaphore closed: {}", e); let error_msg = format!("semaphore closed: {e}");
let mut s = sessions.write().await; {
if let Some(sess) = s.get_mut(&session_id) { let mut s = sessions.write().await;
sess.status = TranscodeStatus::Failed { if let Some(sess) = s.get_mut(&session_id) {
error: error_msg.clone(), sess.status = TranscodeStatus::Failed {
}; error: error_msg.clone(),
};
}
} }
if let Err(e) = storage if let Err(e) = storage
.update_transcode_status( .update_transcode_status(
@ -234,10 +247,12 @@ impl TranscodeService {
.await .await
{ {
Ok(()) => { Ok(()) => {
let mut s = sessions.write().await; {
if let Some(sess) = s.get_mut(&session_id) { let mut s = sessions.write().await;
sess.status = TranscodeStatus::Complete; if let Some(sess) = s.get_mut(&session_id) {
sess.progress = 1.0; sess.status = TranscodeStatus::Complete;
sess.progress = 1.0;
}
} }
if let Err(e) = storage if let Err(e) = storage
.update_transcode_status(session_id, TranscodeStatus::Complete, 1.0) .update_transcode_status(session_id, TranscodeStatus::Complete, 1.0)
@ -277,6 +292,10 @@ impl TranscodeService {
} }
/// Cancel a transcode session and clean up cache files. /// Cancel a transcode session and clean up cache files.
///
/// # Errors
///
/// Returns an error if the database status update fails.
pub async fn cancel_transcode( pub async fn cancel_transcode(
&self, &self,
session_id: Uuid, session_id: Uuid,
@ -359,6 +378,7 @@ impl TranscodeService {
} }
/// Resolve the path to a specific segment file on disk. /// Resolve the path to a specific segment file on disk.
#[must_use]
pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf { pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf {
// Sanitize segment_name to prevent path traversal // Sanitize segment_name to prevent path traversal
let safe_name = std::path::Path::new(segment_name) let safe_name = std::path::Path::new(segment_name)
@ -381,7 +401,7 @@ impl TranscodeService {
.join(safe_name) .join(safe_name)
} }
/// Find a session for a given media_id and profile. /// Find a session for a given `media_id` and profile.
pub async fn find_session( pub async fn find_session(
&self, &self,
media_id: MediaId, media_id: MediaId,
@ -396,24 +416,25 @@ impl TranscodeService {
} }
/// Parse a resolution string like "360p", "720p", "1080p" into (width, height). /// Parse a resolution string like "360p", "720p", "1080p" into (width, height).
#[must_use]
pub fn parse_resolution(res: &str) -> (u32, u32) { pub fn parse_resolution(res: &str) -> (u32, u32) {
match res.trim_end_matches('p') { match res.trim_end_matches('p') {
"360" => (640, 360), "360" => (640, 360),
"480" => (854, 480), "480" => (854, 480),
"720" => (1280, 720),
"1080" => (1920, 1080), "1080" => (1920, 1080),
"1440" => (2560, 1440), "1440" => (2560, 1440),
"2160" | "4k" => (3840, 2160), "2160" | "4k" => (3840, 2160),
_ => (1280, 720), // default to 720p _ => (1280, 720), // default to 720p (includes "720")
} }
} }
/// Estimate bandwidth (bits/sec) from a profile's max_bitrate_kbps. /// Estimate bandwidth (bits/sec) from a profile's `max_bitrate_kbps`.
pub fn estimate_bandwidth(profile: &TranscodeProfile) -> u32 { #[must_use]
pub const fn estimate_bandwidth(profile: &TranscodeProfile) -> u32 {
profile.max_bitrate_kbps * 1000 profile.max_bitrate_kbps * 1000
} }
/// Build FFmpeg CLI arguments for transcoding. /// Build `FFmpeg` CLI arguments for transcoding.
fn get_ffmpeg_args( fn get_ffmpeg_args(
source: &Path, source: &Path,
output_dir: &Path, output_dir: &Path,
@ -441,7 +462,7 @@ fn get_ffmpeg_args(
"-b:v".to_string(), "-b:v".to_string(),
format!("{}k", profile.max_bitrate_kbps), format!("{}k", profile.max_bitrate_kbps),
"-vf".to_string(), "-vf".to_string(),
format!("scale={}:{}", w, h), format!("scale={w}:{h}"),
"-f".to_string(), "-f".to_string(),
"hls".to_string(), "hls".to_string(),
"-hls_time".to_string(), "-hls_time".to_string(),
@ -457,7 +478,7 @@ fn get_ffmpeg_args(
args args
} }
/// Run FFmpeg as a child process, parsing progress from stdout. /// Run `FFmpeg` as a child process, parsing progress from stdout.
async fn run_ffmpeg( async fn run_ffmpeg(
args: &[String], args: &[String],
sessions: &Arc<RwLock<HashMap<Uuid, TranscodeSession>>>, sessions: &Arc<RwLock<HashMap<Uuid, TranscodeSession>>>,
@ -477,33 +498,30 @@ async fn run_ffmpeg(
.spawn() .spawn()
.map_err(|e| { .map_err(|e| {
crate::error::PinakesError::InvalidOperation(format!( crate::error::PinakesError::InvalidOperation(format!(
"failed to spawn ffmpeg: {}", "failed to spawn ffmpeg: {e}"
e
)) ))
})?; })?;
// Capture stderr in a spawned task for error reporting // Capture stderr in a spawned task for error reporting
let stderr_handle = if let Some(stderr) = child.stderr.take() { let stderr_handle = child.stderr.take().map(|stderr| {
let reader = BufReader::new(stderr); let reader = BufReader::new(stderr);
Some(tokio::spawn(async move { tokio::spawn(async move {
let mut lines = reader.lines(); let mut lines = reader.lines();
let mut collected = Vec::new(); let mut collected = Vec::new();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
collected.push(line); collected.push(line);
} }
collected collected
})) })
} else { });
None
};
// Parse progress from stdout // Parse progress from stdout
let stdout_handle = if let Some(stdout) = child.stdout.take() { let stdout_handle = child.stdout.take().map(|stdout| {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut lines = reader.lines(); let mut lines = reader.lines();
let sessions = sessions.clone(); let sessions = Arc::clone(sessions);
Some(tokio::spawn(async move { tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
// FFmpeg progress output: "out_time_us=12345678" // FFmpeg progress output: "out_time_us=12345678"
if let Some(time_str) = line.strip_prefix("out_time_us=") if let Some(time_str) = line.strip_prefix("out_time_us=")
@ -512,7 +530,11 @@ async fn run_ffmpeg(
let secs = us / 1_000_000.0; let secs = us / 1_000_000.0;
// Calculate progress based on known duration // Calculate progress based on known duration
let progress = match duration_secs { let progress = match duration_secs {
Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32, Some(dur) if dur > 0.0 => {
#[allow(clippy::cast_possible_truncation)]
let p = (secs / dur).min(0.99) as f32;
p
},
_ => { _ => {
// Duration unknown; don't update progress // Duration unknown; don't update progress
continue; continue;
@ -524,19 +546,17 @@ async fn run_ffmpeg(
} }
} }
} }
})) })
} else { });
None
};
// Wait for child, but also listen for cancellation // Wait for child, but also listen for cancellation
let status = tokio::select! { let status = tokio::select! {
result = child.wait() => { result = child.wait() => {
result.map_err(|e| { result.map_err(|e| {
crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {}", e)) crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {e}"))
})? })?
} }
_ = cancel.notified() => { () = cancel.notified() => {
// Kill the child process on cancel // Kill the child process on cancel
if let Err(e) = child.kill().await { if let Err(e) = child.kill().await {
tracing::error!("failed to kill ffmpeg process: {}", e); tracing::error!("failed to kill ffmpeg process: {}", e);
@ -569,8 +589,7 @@ async fn run_ffmpeg(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
return Err(crate::error::PinakesError::InvalidOperation(format!( return Err(crate::error::PinakesError::InvalidOperation(format!(
"ffmpeg exited with status: {}\nstderr:\n{}", "ffmpeg exited with status: {status}\nstderr:\n{last_stderr}"
status, last_stderr
))); )));
} }

View file

@ -1,6 +1,6 @@
//! Upload processing for managed storage. //! Upload processing for managed storage.
//! //!
//! Handles file uploads, metadata extraction, and MediaItem creation //! Handles file uploads, metadata extraction, and `MediaItem` creation
//! for files stored in managed content-addressable storage. //! for files stored in managed content-addressable storage.
use std::{collections::HashMap, path::Path}; use std::{collections::HashMap, path::Path};
@ -24,7 +24,11 @@ use crate::{
/// 1. Stores the file in managed storage /// 1. Stores the file in managed storage
/// 2. Checks for duplicates by content hash /// 2. Checks for duplicates by content hash
/// 3. Extracts metadata from the file /// 3. Extracts metadata from the file
/// 4. Creates or updates the MediaItem /// 4. Creates or updates the `MediaItem`
///
/// # Errors
///
/// Returns [`PinakesError`] if storage, hashing, or metadata extraction fails.
pub async fn process_upload<R: AsyncRead + Unpin>( pub async fn process_upload<R: AsyncRead + Unpin>(
storage: &DynStorageBackend, storage: &DynStorageBackend,
managed: &ManagedStorageService, managed: &ManagedStorageService,
@ -54,13 +58,10 @@ pub async fn process_upload<R: AsyncRead + Unpin>(
let blob_path = managed.path(&content_hash); let blob_path = managed.path(&content_hash);
// Extract metadata // Extract metadata
let extracted = let extracted = metadata::extract_metadata(&blob_path, &media_type).ok();
metadata::extract_metadata(&blob_path, media_type.clone()).ok();
// Create or get blob record // Create or get blob record
let mime = mime_type let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from);
.map(String::from)
.unwrap_or_else(|| media_type.mime_type().to_string());
let _blob = storage let _blob = storage
.get_or_create_blob(&content_hash, file_size, &mime) .get_or_create_blob(&content_hash, file_size, &mime)
.await?; .await?;
@ -123,6 +124,10 @@ pub async fn process_upload<R: AsyncRead + Unpin>(
} }
/// Process an upload from bytes. /// Process an upload from bytes.
///
/// # Errors
///
/// Returns [`PinakesError`] if storage or processing fails.
pub async fn process_upload_bytes( pub async fn process_upload_bytes(
storage: &DynStorageBackend, storage: &DynStorageBackend,
managed: &ManagedStorageService, managed: &ManagedStorageService,
@ -138,6 +143,10 @@ pub async fn process_upload_bytes(
/// Process an upload from a local file path. /// Process an upload from a local file path.
/// ///
/// This is useful for migrating existing external files to managed storage. /// This is useful for migrating existing external files to managed storage.
///
/// # Errors
///
/// Returns [`PinakesError`] if the file cannot be opened or processing fails.
pub async fn process_upload_file( pub async fn process_upload_file(
storage: &DynStorageBackend, storage: &DynStorageBackend,
managed: &ManagedStorageService, managed: &ManagedStorageService,
@ -160,6 +169,11 @@ pub async fn process_upload_file(
} }
/// Migrate an existing external media item to managed storage. /// Migrate an existing external media item to managed storage.
///
/// # Errors
///
/// Returns [`PinakesError`] if the media item cannot be found, the file is
/// missing, or the storage migration fails.
pub async fn migrate_to_managed( pub async fn migrate_to_managed(
storage: &DynStorageBackend, storage: &DynStorageBackend,
managed: &ManagedStorageService, managed: &ManagedStorageService,
@ -190,7 +204,7 @@ pub async fn migrate_to_managed(
} }
// Get or create blob record // Get or create blob record
let mime = item.media_type.mime_type().to_string(); let mime = item.media_type.mime_type().clone();
let _blob = storage let _blob = storage
.get_or_create_blob(&new_hash, new_size, &mime) .get_or_create_blob(&new_hash, new_size, &mime)
.await?; .await?;
@ -227,6 +241,11 @@ fn sanitize_filename(name: &str) -> String {
} }
/// Delete a managed media item and clean up the blob if orphaned. /// Delete a managed media item and clean up the blob if orphaned.
///
/// # Errors
///
/// Returns [`PinakesError`] if the media item cannot be found or deletion
/// fails.
pub async fn delete_managed_media( pub async fn delete_managed_media(
storage: &DynStorageBackend, storage: &DynStorageBackend,
managed: &ManagedStorageService, managed: &ManagedStorageService,

View file

@ -17,6 +17,7 @@ pub struct UserId(pub Uuid);
impl UserId { impl UserId {
/// Creates a new user ID. /// Creates a new user ID.
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self(Uuid::now_v7()) Self(Uuid::now_v7())
} }
@ -96,21 +97,25 @@ pub enum LibraryPermission {
impl LibraryPermission { impl LibraryPermission {
/// Checks if read permission is granted. /// Checks if read permission is granted.
pub fn can_read(&self) -> bool { #[must_use]
pub const fn can_read(&self) -> bool {
true true
} }
/// Checks if write permission is granted. /// Checks if write permission is granted.
pub fn can_write(&self) -> bool { #[must_use]
pub const fn can_write(&self) -> bool {
matches!(self, Self::Write | Self::Admin) matches!(self, Self::Write | Self::Admin)
} }
/// Checks if admin permission is granted. /// Checks if admin permission is granted.
pub fn can_admin(&self) -> bool { #[must_use]
pub const fn can_admin(&self) -> bool {
matches!(self, Self::Admin) matches!(self, Self::Admin)
} }
pub fn as_str(&self) -> &'static str { #[must_use]
pub const fn as_str(&self) -> &'static str {
match self { match self {
Self::Read => "read", Self::Read => "read",
Self::Write => "write", Self::Write => "write",
@ -155,7 +160,7 @@ pub struct UpdateUserRequest {
/// User authentication /// User authentication
pub mod auth { pub mod auth {
use super::*; use super::{PinakesError, Result};
/// Hash a password using Argon2 /// Hash a password using Argon2
pub fn hash_password(password: &str) -> Result<String> { pub fn hash_password(password: &str) -> Result<String> {

View file

@ -161,7 +161,7 @@ fn test_book_cover_generation() {
// Verify all cover files exist // Verify all cover files exist
for (size, path) in &covers { for (size, path) in &covers {
assert!(path.exists(), "Cover {:?} should exist at {:?}", size, path); assert!(path.exists(), "Cover {size:?} should exist at {path:?}");
} }
} }
@ -176,13 +176,10 @@ async fn test_openlibrary_isbn_fetch() {
// Should either succeed or fail gracefully // Should either succeed or fail gracefully
// We don't assert success because network might not be available // We don't assert success because network might not be available
match result { if let Ok(book) = result {
Ok(book) => { assert!(book.title.is_some());
assert!(book.title.is_some()); } else {
}, // Network error or book not found - acceptable in tests
Err(_) => {
// Network error or book not found - acceptable in tests
},
} }
} }
@ -195,14 +192,11 @@ async fn test_googlebooks_isbn_fetch() {
// Use a known ISBN // Use a known ISBN
let result = client.fetch_by_isbn("9780547928227").await; let result = client.fetch_by_isbn("9780547928227").await;
match result { if let Ok(books) = result {
Ok(books) => { if !books.is_empty() {
if !books.is_empty() { assert!(books[0].volume_info.title.is_some());
assert!(books[0].volume_info.title.is_some()); }
} } else {
}, // Network error - acceptable in tests
Err(_) => {
// Network error - acceptable in tests
},
} }
} }

View file

@ -158,13 +158,13 @@ async fn test_large_directory_performance() {
storage.add_root_dir(root_dir.clone()).await.unwrap(); storage.add_root_dir(root_dir.clone()).await.unwrap();
for i in 0..1000 { for i in 0..1000 {
let file_path = root_dir.join(format!("file_{}.mp3", i)); let file_path = root_dir.join(format!("file_{i}.mp3"));
fs::write(&file_path, format!("content {}", i)).unwrap(); fs::write(&file_path, format!("content {i}")).unwrap();
} }
for i in 0..500 { for i in 0..500 {
let file_path = root_dir.join(format!("file_{}.mp3", i)); let file_path = root_dir.join(format!("file_{i}.mp3"));
let item = create_test_media_item(file_path, &format!("hash_{}", i)); let item = create_test_media_item(file_path, &format!("hash_{i}"));
storage.insert_media(&item).await.unwrap(); storage.insert_media(&item).await.unwrap();
} }
@ -174,8 +174,7 @@ async fn test_large_directory_performance() {
assert!( assert!(
elapsed.as_secs() < 5, elapsed.as_secs() < 5,
"Detection took too long: {:?}", "Detection took too long: {elapsed:?}"
elapsed
); );
assert_eq!(report.untracked_paths.len(), 500); assert_eq!(report.untracked_paths.len(), 500);

View file

@ -6,7 +6,7 @@ mod common;
fn create_test_note_content(num_links: usize) -> String { fn create_test_note_content(num_links: usize) -> String {
let mut content = String::from("# Test Note\n\n"); let mut content = String::from("# Test Note\n\n");
for i in 0..num_links { for i in 0..num_links {
content.push_str(&format!("Link {}: [[note_{}]]\n", i, i)); content.push_str(&format!("Link {i}: [[note_{i}]]\n"));
} }
content content
} }

View file

@ -121,7 +121,7 @@ async fn test_delete_user_sessions() {
// Create multiple sessions for the same user // Create multiple sessions for the same user
for i in 0..3 { for i in 0..3 {
let session = SessionData { let session = SessionData {
session_token: format!("token_{}", i), session_token: format!("token_{i}"),
user_id: None, user_id: None,
username: "testuser".to_string(), username: "testuser".to_string(),
role: "viewer".to_string(), role: "viewer".to_string(),
@ -152,7 +152,7 @@ async fn test_delete_user_sessions() {
for i in 0..3 { for i in 0..3 {
assert!( assert!(
storage storage
.get_session(&format!("token_{}", i)) .get_session(&format!("token_{i}"))
.await .await
.unwrap() .unwrap()
.is_none() .is_none()
@ -217,7 +217,7 @@ async fn test_list_active_sessions() {
// Create active sessions for different users // Create active sessions for different users
for i in 0..3 { for i in 0..3 {
let session = SessionData { let session = SessionData {
session_token: format!("user1_token_{}", i), session_token: format!("user1_token_{i}"),
user_id: None, user_id: None,
username: "user1".to_string(), username: "user1".to_string(),
role: "viewer".to_string(), role: "viewer".to_string(),
@ -230,7 +230,7 @@ async fn test_list_active_sessions() {
for i in 0..2 { for i in 0..2 {
let session = SessionData { let session = SessionData {
session_token: format!("user2_token_{}", i), session_token: format!("user2_token_{i}"),
user_id: None, user_id: None,
username: "user2".to_string(), username: "user2".to_string(),
role: "admin".to_string(), role: "admin".to_string(),
@ -279,9 +279,9 @@ async fn test_concurrent_session_operations() {
let storage = storage.clone(); let storage = storage.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let session = SessionData { let session = SessionData {
session_token: format!("concurrent_{}", i), session_token: format!("concurrent_{i}"),
user_id: None, user_id: None,
username: format!("user{}", i), username: format!("user{i}"),
role: "viewer".to_string(), role: "viewer".to_string(),
created_at: now, created_at: now,
expires_at: now + chrono::Duration::hours(24), expires_at: now + chrono::Duration::hours(24),
@ -301,7 +301,7 @@ async fn test_concurrent_session_operations() {
for i in 0..10 { for i in 0..10 {
assert!( assert!(
storage storage
.get_session(&format!("concurrent_{}", i)) .get_session(&format!("concurrent_{i}"))
.await .await
.unwrap() .unwrap()
.is_some() .is_some()