use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub mod markdown;
pub mod sqlite;
pub use sqlite::Storage;
pub fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
}
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Text,
Image,
File,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Text => "text",
Kind::Image => "image",
Kind::File => "file",
}
}
}
pub(crate) fn hex_lower(bytes: &[u8; 32]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(64);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
pub(crate) fn parse_hex(s: &str) -> crate::error::Result<[u8; 32]> {
use crate::error::Error;
if s.len() != 64 {
return Err(Error::Storage(format!(
"sha256 hex must be 64 chars, got {}",
s.len()
)));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = hex_nibble(s.as_bytes()[i * 2])?;
let lo = hex_nibble(s.as_bytes()[i * 2 + 1])?;
*byte = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(c: u8) -> crate::error::Result<u8> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(crate::error::Error::Storage(format!(
"non-hex char {:?} in sha256",
c as char
))),
}
}
#[derive(Debug, Clone)]
pub struct SearchHit {
pub row: CaptureRow,
pub duplicate_of: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct CaptureRow {
pub id: i64,
pub ts: DateTime<Utc>,
pub kind: Kind,
pub sha256: [u8; 32],
pub size_bytes: usize,
pub content: Option<String>,
pub ocr_confidence: Option<f32>,
pub source_app: Option<String>,
pub source_url: Option<String>,
pub md_path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_serializes_lowercase() {
assert_eq!(Kind::Text.as_str(), "text");
assert_eq!(Kind::Image.as_str(), "image");
assert_eq!(Kind::File.as_str(), "file");
}
#[test]
fn kind_serde_roundtrips_lowercase() {
let json = serde_json::to_string(&Kind::Image).unwrap();
assert_eq!(json, "\"image\"");
let back: Kind = serde_json::from_str("\"file\"").unwrap();
assert_eq!(back, Kind::File);
}
#[test]
fn hex_lower_is_64_chars_lowercase() {
let h = hex_lower(&[0xAB; 32]);
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
assert_eq!(h, "ab".repeat(32));
}
#[test]
fn parse_hex_roundtrips() {
let original = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0]
.into_iter()
.cycle()
.take(32)
.collect::<Vec<_>>();
let mut arr = [0u8; 32];
arr.copy_from_slice(&original);
let hex = hex_lower(&arr);
let back = parse_hex(&hex).unwrap();
assert_eq!(arr, back);
}
#[test]
fn parse_hex_accepts_uppercase() {
let upper = "AB".repeat(32);
let bytes = parse_hex(&upper).expect("uppercase hex should parse");
assert_eq!(bytes, [0xab; 32]);
}
#[test]
fn parse_hex_rejects_wrong_length() {
let err = parse_hex("deadbeef").unwrap_err();
assert!(format!("{err}").contains("64 chars"));
}
#[test]
fn parse_hex_rejects_non_hex_char() {
let bad = "g".to_string() + &"a".repeat(63);
let err = parse_hex(&bad).unwrap_err();
assert!(format!("{err}").contains("non-hex"));
}
}