use anyhow::{Context, Result, bail}; use rusqlite::OptionalExtension; use std::{ env, fs, path::{Path, PathBuf}, }; use crate::database; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ArchivePaths { pub archive_path: PathBuf, pub store_path: PathBuf, pub name: String, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntrySummary { pub entry_uid: String, pub archived_at: String, pub source_kind: String, pub entity_kind: String, pub title: Option, pub visibility: String, pub original_url: Option, pub artifact_count: i64, pub total_artifact_bytes: i64, pub parent_entry_uid: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntryDetail { pub summary: EntrySummary, pub structured_root_relpath: String, pub source_metadata_json: String, pub display_metadata_json: Option, pub artifacts: Vec, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntryArtifactSummary { pub artifact_role: String, pub storage_area: String, pub relpath: String, pub byte_size: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct RunSummary { pub run_uid: String, pub started_at: String, pub finished_at: Option, pub status: String, pub requested_count: i64, pub discovered_count: i64, pub completed_count: i64, pub failed_count: i64, pub error_summary: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct Tag { pub tag_uid: String, pub name: String, pub slug: String, pub full_path: String, } #[derive(Debug, Clone, serde::Serialize)] pub struct TagNode { pub tag: Tag, pub children: Vec, } pub fn find_archive_path_from(start: &Path) -> Result> { let mut dir = start.to_path_buf(); loop { let candidate = dir.join(".archivr"); if candidate.is_dir() { return Ok(Some(candidate)); } if !dir.pop() { return Ok(None); } } } pub fn find_archive_path() -> Result> { let cwd = env::current_dir().context("failed to read current working directory")?; find_archive_path_from(&cwd) } pub fn read_archive_paths(archive_path: &Path) -> Result { if !archive_path.is_dir() { bail!("archive path does not exist: {}", archive_path.display()); } let name = fs::read_to_string(archive_path.join("name")) .with_context(|| format!("failed to read archive name in {}", archive_path.display()))? .trim() .to_string(); let store_path = fs::read_to_string(archive_path.join("store_path")) .with_context(|| format!("failed to read store path in {}", archive_path.display()))?; Ok(ArchivePaths { archive_path: archive_path.to_path_buf(), store_path: PathBuf::from(store_path.trim()), name, }) } pub fn initialize_archive( archive_parent: &Path, store_path: &Path, archive_name: &str, force_with_info_removal: bool, ) -> Result { let archive_path = archive_parent.join(".archivr"); if archive_path.exists() { if !archive_path.is_dir() { bail!( "Archive path exists and is not a directory: {}", archive_path.display() ); } if force_with_info_removal { fs::remove_dir_all(&archive_path)?; } else if fs::read_dir(&archive_path)?.next().is_some() { bail!( "Archive already exists at {} and is not empty. Use --force-with-info-removal to reinitialize.", archive_path.display() ); } } if store_path.exists() && !force_with_info_removal { bail!("Store path already exists at {}", store_path.display()); } fs::create_dir_all(&archive_path)?; fs::create_dir_all(store_path)?; fs::write(archive_path.join("name"), archive_name)?; let canonical_store_path = store_path .canonicalize() .with_context(|| format!("failed to canonicalize {}", store_path.display()))?; fs::write( archive_path.join("store_path"), canonical_store_path .to_str() .context("store path is not valid UTF-8")?, )?; initialize_store_directories(&canonical_store_path)?; let conn = database::open_or_initialize(&archive_path)?; let _ = database::ensure_default_user(&conn)?; Ok(ArchivePaths { archive_path, store_path: canonical_store_path, name: archive_name.to_string(), }) } pub fn initialize_store_directories(store_path: &Path) -> Result<()> { fs::create_dir_all(store_path.join("raw"))?; fs::create_dir_all(store_path.join("raw_tweets"))?; fs::create_dir_all(store_path.join("structured"))?; fs::create_dir_all(store_path.join("temp"))?; Ok(()) } pub fn list_root_entries(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare( "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, NULL AS parent_entry_uid FROM archived_entries e JOIN source_identities si ON si.id = e.source_identity_id LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id LEFT JOIN blobs b ON b.id = ea.blob_id WHERE e.parent_entry_id IS NULL GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC", )?; let entries = stmt .query_map([], |row| { Ok(EntrySummary { entry_uid: row.get(0)?, archived_at: row.get(1)?, source_kind: row.get(2)?, entity_kind: row.get(3)?, title: row.get(4)?, visibility: row.get(5)?, original_url: row.get(6)?, artifact_count: row.get(7)?, total_artifact_bytes: row.get(8)?, parent_entry_uid: row.get(9)?, }) })? .collect::>>()?; Ok(entries) } pub fn get_entry_detail( conn: &rusqlite::Connection, entry_uid: &str, ) -> Result> { let Some((entry_id, structured_root_relpath, source_metadata_json, display_metadata_json)) = conn.query_row( "SELECT id, structured_root_relpath, source_metadata_json, display_metadata_json FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, )) }, ) .optional()? else { return Ok(None); }; let summary = list_root_entries(conn)? .into_iter() .find(|entry| entry.entry_uid == entry_uid) .context("entry disappeared while loading detail")?; let mut stmt = conn.prepare( "SELECT ea.artifact_role, ea.storage_area, ea.relpath, b.byte_size FROM entry_artifacts ea LEFT JOIN blobs b ON b.id = ea.blob_id WHERE ea.entry_id = ?1 ORDER BY ea.id ASC", )?; let artifacts = stmt .query_map([entry_id], |row| { Ok(EntryArtifactSummary { artifact_role: row.get(0)?, storage_area: row.get(1)?, relpath: row.get(2)?, byte_size: row.get(3)?, }) })? .collect::>>()?; Ok(Some(EntryDetail { summary, structured_root_relpath, source_metadata_json, display_metadata_json, artifacts, })) } pub fn list_runs(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare( "SELECT run_uid, started_at, finished_at, status, requested_count, discovered_count, completed_count, failed_count, error_summary FROM archive_runs ORDER BY started_at DESC, id DESC", )?; let runs = stmt .query_map([], |row| { Ok(RunSummary { run_uid: row.get(0)?, started_at: row.get(1)?, finished_at: row.get(2)?, status: row.get(3)?, requested_count: row.get(4)?, discovered_count: row.get(5)?, completed_count: row.get(6)?, failed_count: row.get(7)?, error_summary: row.get(8)?, }) })? .collect::>>()?; Ok(runs) } /// Resolves an artifact to its absolute on-disk path under `store_path`. /// /// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`). /// The returned path is canonicalized. Returns an error if the resolved path /// escapes `store_path` (path traversal protection) or if the file does not exist. pub fn resolve_artifact_path( store_path: &Path, artifact: &EntryArtifactSummary, ) -> Result { let joined = store_path.join(&artifact.relpath); let canonical_store = store_path .canonicalize() .with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?; let canonical_artifact = joined .canonicalize() .with_context(|| format!("artifact path does not exist: {}", joined.display()))?; if !canonical_artifact.starts_with(&canonical_store) { bail!( "artifact path escapes store: {}", canonical_artifact.display() ); } Ok(canonical_artifact) } #[derive(Debug, Clone, Default)] pub struct SearchEntriesQuery { /// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility pub q: Option, /// Exact match on e.source_kind pub source_kind: Option, /// Exact match on e.entity_kind pub entity_kind: Option, /// LIKE-matched against si.canonical_url pub url: Option, /// LIKE-matched against e.title pub title: Option, /// e.archived_at >= after (inclusive, ISO 8601) pub after: Option, /// e.archived_at < before (exclusive, ISO 8601) pub before: Option, /// Tag full_path filter; includes all entries (root + child) matching the tag subtree pub tag: Option, } /// Parses a raw search string into a [`SearchEntriesQuery`]. /// /// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`. /// Tokens with an unrecognized `prefix:` return `Err(prefix)`. /// Remaining non-prefix tokens are joined as the free-text `q`. /// Quoted values (`title:"resume templates"`) are supported for single-word values /// after the colon; leading/trailing double quotes are stripped. pub fn parse_search_query(raw: &str) -> Result { let mut query = SearchEntriesQuery::default(); let mut free_text_tokens: Vec<&str> = Vec::new(); for token in raw.split_whitespace() { if let Some(colon_pos) = token.find(':') { let prefix = &token[..colon_pos]; let value_raw = &token[colon_pos + 1..]; // Strip surrounding double quotes if present let value = value_raw.trim_matches('"').to_string(); match prefix { "source" => query.source_kind = Some(value), "type" => query.entity_kind = Some(value), "url" => query.url = Some(value), "title" => query.title = Some(value), "after" => query.after = Some(value), "before" => query.before = Some(value), "tag" => query.tag = Some(value), other => return Err(other.to_string()), } } else { free_text_tokens.push(token); } } let q = free_text_tokens.join(" "); query.q = if q.is_empty() { None } else { Some(q) }; Ok(query) } const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \ parent.entry_uid AS parent_entry_uid"; const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \ JOIN source_identities si ON si.id = e.source_identity_id \ LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ LEFT JOIN blobs b ON b.id = ea.blob_id \ LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id"; /// Searches archived entries matching all non-`None` fields in `query`. /// /// Without a tag filter, returns root entries only (same scope as [`list_root_entries`]). /// With a tag filter, returns ALL entries (root and child) assigned to that tag subtree. pub fn search_entries( conn: &rusqlite::Connection, query: &SearchEntriesQuery, ) -> Result> { let mut params: Vec = Vec::new(); let mut sql; if let Some(tag_path) = &query.tag { params.push(tag_path.clone()); sql = format!( "WITH RECURSIVE descendants(id) AS (\ SELECT id FROM tags WHERE full_path = ?1 \ UNION ALL \ SELECT child.id FROM tags child \ JOIN descendants d ON child.parent_tag_id = d.id\ ) {} {} \ JOIN entry_tag_assignments eta ON eta.entry_id = e.id \ JOIN descendants d ON eta.tag_id = d.id \ WHERE 1=1", ENTRY_SELECT_COLS, ENTRY_FROM_JOINS, ); } else { sql = format!( "{} {} WHERE e.parent_entry_id IS NULL", ENTRY_SELECT_COLS, ENTRY_FROM_JOINS, ); } if let Some(q) = &query.q { let term = format!("%{}%", q.to_lowercase()); let n = params.len() + 1; sql.push_str(&format!( " AND (LOWER(e.title) LIKE ?{n} OR LOWER(si.canonical_url) LIKE ?{n} \ OR LOWER(e.entry_uid) LIKE ?{n} OR LOWER(e.source_kind) LIKE ?{n} \ OR LOWER(e.entity_kind) LIKE ?{n} OR LOWER(e.visibility) LIKE ?{n})" )); params.push(term); } if let Some(sk) = &query.source_kind { let n = params.len() + 1; sql.push_str(&format!(" AND e.source_kind = ?{n}")); params.push(sk.clone()); } if let Some(ek) = &query.entity_kind { let n = params.len() + 1; sql.push_str(&format!(" AND e.entity_kind = ?{n}")); params.push(ek.clone()); } if let Some(u) = &query.url { let n = params.len() + 1; sql.push_str(&format!(" AND LOWER(si.canonical_url) LIKE ?{n}")); params.push(format!("%{}%", u.to_lowercase())); } if let Some(t) = &query.title { let n = params.len() + 1; sql.push_str(&format!(" AND LOWER(e.title) LIKE ?{n}")); params.push(format!("%{}%", t.to_lowercase())); } if let Some(a) = &query.after { let n = params.len() + 1; sql.push_str(&format!(" AND e.archived_at >= ?{n}")); params.push(a.clone()); } if let Some(b) = &query.before { let n = params.len() + 1; sql.push_str(&format!(" AND e.archived_at < ?{n}")); params.push(b.clone()); } sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC"); let mut stmt = conn.prepare(&sql)?; let entries = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { Ok(EntrySummary { entry_uid: row.get(0)?, archived_at: row.get(1)?, source_kind: row.get(2)?, entity_kind: row.get(3)?, title: row.get(4)?, visibility: row.get(5)?, original_url: row.get(6)?, artifact_count: row.get(7)?, total_artifact_bytes: row.get(8)?, parent_entry_uid: row.get(9)?, }) })? .collect::>>()?; Ok(entries) } fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result { conn.query_row( "SELECT tag_uid, name, slug, full_path FROM tags WHERE id = ?1", [id], |row| { Ok(Tag { tag_uid: row.get(0)?, name: row.get(1)?, slug: row.get(2)?, full_path: row.get(3)?, }) }, ) .context("tag not found by id") } /// Creates all tag path segments (idempotent) and returns the leaf `Tag`. pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result { let id = database::create_tag_path(conn, full_path)?; tag_by_id(conn, id) } /// Returns the full tag tree with root nodes at the top level and children nested. pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result> { use std::collections::HashMap; let records = database::list_all_tags(conn)?; let mut by_parent: HashMap, Vec> = HashMap::new(); for record in records { by_parent.entry(record.parent_tag_id).or_default().push(record); } fn build_nodes( parent_id: Option, by_parent: &HashMap, Vec>, ) -> Vec { let Some(children) = by_parent.get(&parent_id) else { return Vec::new(); }; children .iter() .map(|r| TagNode { tag: Tag { tag_uid: r.tag_uid.clone(), name: r.name.clone(), slug: r.slug.clone(), full_path: r.full_path.clone(), }, children: build_nodes(Some(r.id), by_parent), }) .collect() } Ok(build_nodes(None, &by_parent)) } /// Returns the tags assigned to an entry. /// /// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404). /// Returns `Ok(Some([]))` if the entry exists but has no tags. pub fn get_entry_tags( conn: &rusqlite::Connection, entry_uid: &str, ) -> Result>> { let Some(entry_id) = conn .query_row( "SELECT id FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| row.get::<_, i64>(0), ) .optional()? else { return Ok(None); }; let records = database::list_tags_for_entry(conn, entry_id)?; Ok(Some( records .into_iter() .map(|r| Tag { tag_uid: r.tag_uid, name: r.name, slug: r.slug, full_path: r.full_path, }) .collect(), )) } /// Assigns a tag (by full path, creating it if needed) to an entry. /// /// Returns `Ok(None)` if the entry_uid does not exist. /// Returns `Ok(Some(tag))` on success. pub fn assign_entry_tag( conn: &rusqlite::Connection, entry_uid: &str, tag_full_path: &str, ) -> Result> { let Some(entry_id) = conn .query_row( "SELECT id FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| row.get::<_, i64>(0), ) .optional()? else { return Ok(None); }; let tag_id = database::create_tag_path(conn, tag_full_path)?; database::assign_entry_to_tag(conn, entry_id, tag_id)?; Ok(Some(tag_by_id(conn, tag_id)?)) } /// Removes a tag assignment from an entry. /// /// Returns `Ok(false)` if either the entry_uid or tag_uid is not found. /// Returns `Ok(true)` on success (even if no row was deleted, i.e. assignment didn't exist). pub fn remove_entry_tag( conn: &rusqlite::Connection, entry_uid: &str, tag_uid: &str, ) -> Result { let Some(entry_id) = conn .query_row( "SELECT id FROM archived_entries WHERE entry_uid = ?1", [entry_uid], |row| row.get::<_, i64>(0), ) .optional()? else { return Ok(false); }; let Some(tag_record) = database::get_tag_by_uid(conn, tag_uid)? else { return Ok(false); }; database::remove_entry_tag_assignment(conn, entry_id, tag_record.id)?; Ok(true) } /// Returns all entries (root and child) assigned to any tag in the subtree rooted at `tag_full_path`. pub fn entries_for_tag( conn: &rusqlite::Connection, tag_full_path: &str, ) -> Result> { let mut stmt = conn.prepare( "WITH RECURSIVE descendants(id) AS ( SELECT id FROM tags WHERE full_path = ?1 UNION ALL SELECT child.id FROM tags child JOIN descendants d ON child.parent_tag_id = d.id ) SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, parent.entry_uid AS parent_entry_uid FROM archived_entries e JOIN source_identities si ON si.id = e.source_identity_id LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id LEFT JOIN blobs b ON b.id = ea.blob_id LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id JOIN entry_tag_assignments eta ON eta.entry_id = e.id JOIN descendants d ON eta.tag_id = d.id GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC", )?; let entries = stmt .query_map([tag_full_path], |row| { Ok(EntrySummary { entry_uid: row.get(0)?, archived_at: row.get(1)?, source_kind: row.get(2)?, entity_kind: row.get(3)?, title: row.get(4)?, visibility: row.get(5)?, original_url: row.get(6)?, artifact_count: row.get(7)?, total_artifact_bytes: row.get(8)?, parent_entry_uid: row.get(9)?, }) })? .collect::>>()?; Ok(entries) } #[cfg(test)] mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; fn unique_path(prefix: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id())) } #[test] fn find_archive_path_walks_up_to_dot_archivr() { let root = unique_path("archivr-core-find"); let nested = root.join("a").join("b"); fs::create_dir_all(root.join(".archivr")).unwrap(); fs::create_dir_all(&nested).unwrap(); let found = find_archive_path_from(&nested).unwrap().unwrap(); assert_eq!(found, root.join(".archivr")); let _ = fs::remove_dir_all(root); } #[test] fn read_archive_paths_returns_name_and_store_path() { let root = unique_path("archivr-core-open"); let archive_path = root.join(".archivr"); let store_path = root.join("store"); fs::create_dir_all(&archive_path).unwrap(); fs::create_dir_all(&store_path).unwrap(); fs::write(archive_path.join("name"), "Personal").unwrap(); fs::write( archive_path.join("store_path"), store_path.display().to_string(), ) .unwrap(); let paths = read_archive_paths(&archive_path).unwrap(); assert_eq!(paths.archive_path, archive_path); assert_eq!(paths.store_path, store_path); assert_eq!(paths.name, "Personal"); let _ = fs::remove_dir_all(root); } #[test] fn initialize_archive_creates_database_store_and_metadata() { let root = unique_path("archivr-core-init"); let archive_parent = root.join("archive"); let store_path = root.join("store"); let paths = initialize_archive(&archive_parent, &store_path, "Personal", false).unwrap(); assert_eq!(paths.archive_path, archive_parent.join(".archivr")); assert!( paths .archive_path .join(database::DATABASE_FILE_NAME) .is_file() ); assert!(paths.store_path.join("raw").is_dir()); assert!(paths.store_path.join("raw_tweets").is_dir()); assert!(paths.store_path.join("structured").is_dir()); assert!(paths.store_path.join("temp").is_dir()); let _ = fs::remove_dir_all(root); } #[test] fn list_root_entries_returns_entry_details_and_runs() { let conn = rusqlite::Connection::open_in_memory().unwrap(); database::initialize_schema(&conn).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); let run = database::create_archive_run(&conn, user_id, 1).unwrap(); let item = database::create_archive_run_item( &conn, run.id, None, 0, "https://example.com/saved", Some("https://example.com/saved"), "web", "page", ) .unwrap(); let source_identity_id = database::upsert_source_identity( &conn, "web", "page", Some("saved-article"), Some("https://example.com/saved"), "https://example.com/saved", ) .unwrap(); let entry = database::create_archived_entry( &conn, &database::NewEntry { source_identity_id, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "web".to_string(), entity_kind: "page".to_string(), title: Some("Saved Article".to_string()), visibility: "private".to_string(), representation_kind: "html".to_string(), source_metadata_json: r#"{"source":"test"}"#.to_string(), display_metadata_json: Some(r#"{"reading_time":"4m"}"#.to_string()), }, ) .unwrap(); let blob_id = database::upsert_blob( &conn, &database::BlobRecord { sha256: "abc123".to_string(), byte_size: 123, mime_type: Some("text/html".to_string()), extension: Some("html".to_string()), raw_relpath: "raw/a/b/abc123.html".to_string(), }, ) .unwrap(); database::add_entry_artifact( &conn, &database::NewArtifact { entry_id: entry.id, artifact_role: "primary_media".to_string(), storage_area: "raw".to_string(), relpath: "raw/a/b/abc123.html".to_string(), blob_id: Some(blob_id), logical_path: None, metadata_json: None, }, ) .unwrap(); database::complete_archive_run_item(&conn, item.id, entry.id).unwrap(); database::finish_archive_run(&conn, run.id).unwrap(); let entries = list_root_entries(&conn).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title.as_deref(), Some("Saved Article")); assert_eq!(entries[0].artifact_count, 1); let detail = get_entry_detail(&conn, &entries[0].entry_uid) .unwrap() .unwrap(); assert_eq!(detail.artifacts.len(), 1); assert_eq!(detail.artifacts[0].artifact_role, "primary_media"); let runs = list_runs(&conn).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].status, "completed"); } #[test] fn resolve_artifact_path_returns_absolute_path_within_store() { let root = unique_path("archivr-resolve-artifact"); let store_path = root.join("store"); fs::create_dir_all(store_path.join("raw/a/b")).unwrap(); let artifact_file = store_path.join("raw/a/b/abc.pdf"); fs::write(&artifact_file, b"data").unwrap(); let artifact = EntryArtifactSummary { artifact_role: "primary".to_string(), storage_area: "raw".to_string(), relpath: "raw/a/b/abc.pdf".to_string(), byte_size: Some(4), }; let resolved = resolve_artifact_path(&store_path, &artifact).unwrap(); assert_eq!(resolved, artifact_file.canonicalize().unwrap()); let _ = fs::remove_dir_all(&root); } #[test] fn resolve_artifact_path_rejects_traversal() { let root = unique_path("archivr-resolve-traversal"); let store_path = root.join("store"); fs::create_dir_all(&store_path).unwrap(); let artifact = EntryArtifactSummary { artifact_role: "primary".to_string(), storage_area: "raw".to_string(), relpath: "../escaped.txt".to_string(), byte_size: None, }; assert!(resolve_artifact_path(&store_path, &artifact).is_err()); let _ = fs::remove_dir_all(&root); } // ---- parse_search_query tests ---- #[test] fn parse_empty_query_returns_default() { let q = parse_search_query("").unwrap(); assert!(q.q.is_none()); assert!(q.source_kind.is_none()); assert!(q.entity_kind.is_none()); } #[test] fn parse_plain_text_sets_q() { let q = parse_search_query("polymarket").unwrap(); assert_eq!(q.q.as_deref(), Some("polymarket")); } #[test] fn parse_prefix_source_sets_source_kind() { let q = parse_search_query("source:x").unwrap(); assert_eq!(q.source_kind.as_deref(), Some("x")); assert!(q.q.is_none()); } #[test] fn parse_prefix_type_sets_entity_kind() { let q = parse_search_query("type:tweet").unwrap(); assert_eq!(q.entity_kind.as_deref(), Some("tweet")); } #[test] fn parse_mixed_plain_and_prefix() { let q = parse_search_query("polymarket type:tweet").unwrap(); assert_eq!(q.q.as_deref(), Some("polymarket")); assert_eq!(q.entity_kind.as_deref(), Some("tweet")); } #[test] fn parse_unknown_prefix_returns_err() { let result = parse_search_query("foo:bar"); assert!(result.is_err()); assert_eq!(result.unwrap_err(), "foo"); } #[test] fn parse_after_before_dates() { let q = parse_search_query("after:2026-01-01 before:2026-04-01").unwrap(); assert_eq!(q.after.as_deref(), Some("2026-01-01")); assert_eq!(q.before.as_deref(), Some("2026-04-01")); } #[test] fn parse_search_query_tag_prefix() { let q = parse_search_query("tag:/science/cs").unwrap(); assert_eq!(q.tag.as_deref(), Some("/science/cs")); assert_eq!(q.q, None); } // ---- search_entries tests ---- fn make_test_db_with_entries() -> rusqlite::Connection { let conn = rusqlite::Connection::open_in_memory().unwrap(); database::initialize_schema(&conn).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); let run = database::create_archive_run(&conn, user_id, 2).unwrap(); // Entry 1: tweet by source x let si1 = database::upsert_source_identity( &conn, "x", "tweet", Some("t-1"), Some("https://x.com/user/status/1"), "https://x.com/user/status/1", ).unwrap(); database::create_archived_entry( &conn, &database::NewEntry { source_identity_id: si1, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "x".to_string(), entity_kind: "tweet".to_string(), title: Some("Polymarket tweet".to_string()), visibility: "private".to_string(), representation_kind: "json".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ).unwrap(); // Entry 2: web page let si2 = database::upsert_source_identity( &conn, "web", "page", Some("page-1"), Some("https://medium.com/article"), "https://medium.com/article", ).unwrap(); database::create_archived_entry( &conn, &database::NewEntry { source_identity_id: si2, archive_run_id: run.id, parent_entry_id: None, root_entry_id: None, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "web".to_string(), entity_kind: "page".to_string(), title: Some("Resume Templates".to_string()), visibility: "private".to_string(), representation_kind: "html".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ).unwrap(); conn } #[test] fn search_empty_query_returns_all_root_entries() { let conn = make_test_db_with_entries(); let all = list_root_entries(&conn).unwrap(); let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); assert_eq!(all.len(), searched.len()); } #[test] fn search_q_filters_on_title() { let conn = make_test_db_with_entries(); let results = search_entries(&conn, &SearchEntriesQuery { q: Some("polymarket".to_string()), ..Default::default() }).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entity_kind, "tweet"); } #[test] fn search_entity_kind_exact_match() { let conn = make_test_db_with_entries(); let results = search_entries(&conn, &SearchEntriesQuery { entity_kind: Some("page".to_string()), ..Default::default() }).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].source_kind, "web"); } #[test] fn search_url_like_filter() { let conn = make_test_db_with_entries(); let results = search_entries(&conn, &SearchEntriesQuery { url: Some("medium.com".to_string()), ..Default::default() }).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].title.as_deref(), Some("Resume Templates")); } #[test] fn search_no_match_returns_empty() { let conn = make_test_db_with_entries(); let results = search_entries(&conn, &SearchEntriesQuery { q: Some("zzznonexistent".to_string()), ..Default::default() }).unwrap(); assert!(results.is_empty()); } #[test] fn search_multiple_filters_compound() { let conn = make_test_db_with_entries(); let results = search_entries(&conn, &SearchEntriesQuery { source_kind: Some("x".to_string()), entity_kind: Some("tweet".to_string()), ..Default::default() }).unwrap(); assert_eq!(results.len(), 1); } // ---- tag API tests ---- fn make_tag_test_db() -> (rusqlite::Connection, i64, i64) { let conn = rusqlite::Connection::open_in_memory().unwrap(); database::initialize_schema(&conn).unwrap(); let user_id = database::ensure_default_user(&conn).unwrap(); let run = database::create_archive_run(&conn, user_id, 2).unwrap(); (conn, user_id, run.id) } fn make_entry_in_db( conn: &rusqlite::Connection, user_id: i64, run_id: i64, parent_entry_id: Option, root_entry_id: Option, title: &str, url: &str, ) -> database::ArchivedEntry { let si = database::upsert_source_identity( conn, "web", "page", None, Some(url), url, ).unwrap(); database::create_archived_entry( conn, &database::NewEntry { source_identity_id: si, archive_run_id: run_id, parent_entry_id, root_entry_id, created_by_user_id: user_id, owned_by_user_id: user_id, source_kind: "web".to_string(), entity_kind: "page".to_string(), title: Some(title.to_string()), visibility: "private".to_string(), representation_kind: "html".to_string(), source_metadata_json: "{}".to_string(), display_metadata_json: None, }, ).unwrap() } #[test] fn tag_tree_roots_and_children() { let (conn, _, _) = make_tag_test_db(); create_tag(&conn, "/science/cs").unwrap(); create_tag(&conn, "/art").unwrap(); let tree = list_tag_tree(&conn).unwrap(); assert_eq!(tree.len(), 2, "expected two root nodes"); let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing"); assert_eq!(science.children.len(), 1, "science should have one child"); assert_eq!(science.children[0].tag.slug, "cs"); let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing"); assert!(art.children.is_empty(), "art should have no children"); } #[test] fn assign_entry_tag_is_idempotent() { let (conn, user_id, run_id) = make_tag_test_db(); let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t1"); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag"); } #[test] fn remove_entry_tag_clears_assignment() { let (conn, user_id, run_id) = make_tag_test_db(); let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t2"); let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap().unwrap(); remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap(); let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); assert!(tags.is_empty(), "tag should be removed"); } #[test] fn entries_for_tag_includes_descendants() { let (conn, user_id, run_id) = make_tag_test_db(); let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Compilers Paper", "https://example.com/c1"); assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap(); let results = entries_for_tag(&conn, "/science").unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entry_uid, entry.entry_uid); } #[test] fn entries_for_tag_includes_child_entries() { let (conn, user_id, run_id) = make_tag_test_db(); let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", "https://example.com/pl"); let child = make_entry_in_db( &conn, user_id, run_id, Some(parent.id), Some(parent.id), "Video 1", "https://example.com/pl/v1", ); assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap(); let results = entries_for_tag(&conn, "/science").unwrap(); assert_eq!(results.len(), 1, "only the tagged child should appear"); assert_eq!(results[0].entry_uid, child.entry_uid); assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str())); } #[test] fn search_with_tag_filter_works() { let (conn, user_id, run_id) = make_tag_test_db(); let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Science Article", "https://example.com/s1"); assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); let results = search_entries(&conn, &SearchEntriesQuery { tag: Some("/science".to_string()), ..Default::default() }).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entry_uid, entry.entry_uid); let empty = search_entries(&conn, &SearchEntriesQuery { tag: Some("/art".to_string()), ..Default::default() }).unwrap(); assert!(empty.is_empty(), "no entries under /art"); } #[test] fn search_without_tag_returns_roots_only() { let (conn, user_id, run_id) = make_tag_test_db(); let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Parent", "https://example.com/par"); let _child = make_entry_in_db( &conn, user_id, run_id, Some(parent.id), Some(parent.id), "Child", "https://example.com/par/c", ); let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); assert_eq!(results.len(), 1, "plain search should return root entries only"); assert_eq!(results[0].entry_uid, parent.entry_uid); } }