1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/crates/archivr-core/src/archive.rs
TheGeneralist 289037235c
feat(tags): revamp tags tab (#29)
feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
2026-07-19 11:02:57 +02:00

1621 lines
53 KiB
Rust

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<String>,
pub visibility: String,
pub original_url: Option<String>,
pub artifact_count: i64,
pub total_artifact_bytes: i64,
pub parent_entry_uid: Option<String>,
/// True if a `favicon` artifact exists for this entry.
pub has_favicon: bool,
/// Bytes of blobs already on disk from an earlier entry (precomputed at capture time).
pub cached_bytes: i64,
}
#[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<String>,
pub artifacts: Vec<EntryArtifactSummary>,
}
#[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<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RunSummary {
pub run_uid: String,
pub started_at: String,
pub finished_at: Option<String>,
pub status: String,
pub requested_count: i64,
pub discovered_count: i64,
pub completed_count: i64,
pub failed_count: i64,
pub error_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CaptureJobSummary {
pub job_uid: String,
pub archive_id: String,
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub notes_json: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[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 entry_count: i64,
pub subtree_count: i64,
pub children: Vec<TagNode>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CollectionSummary {
pub collection_uid: String,
pub name: String,
pub slug: String,
pub default_visibility_bits: u32,
pub created_at: String,
}
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
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<Option<PathBuf>> {
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<ArchivePaths> {
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<ArchivePaths> {
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,
caller_bits: u32,
) -> Result<Vec<EntrySummary>> {
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,
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
e.cached_bytes
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
AND (
CAST(?1 AS INTEGER) & 12 != 0
OR EXISTS (
SELECT 1 FROM collection_entries ce
WHERE ce.entry_id = e.id
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
)
)
GROUP BY e.id
ORDER BY e.archived_at DESC, e.id DESC",
)?;
let entries = stmt
.query_map([caller_bits as i64], |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)?,
has_favicon: row.get::<_, i64>(10)? != 0,
cached_bytes: row.get(11)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
pub fn get_entry_detail(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<EntryDetail>> {
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<String>>(3)?,
))
},
)
.optional()?
else {
return Ok(None);
};
let summary = list_root_entries(conn, u32::MAX)?
.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::<rusqlite::Result<Vec<_>>>()?;
Ok(Some(EntryDetail {
summary,
structured_root_relpath,
source_metadata_json,
display_metadata_json,
artifacts,
}))
}
pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
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::<rusqlite::Result<Vec<_>>>()?;
Ok(runs)
}
pub fn get_capture_job(
conn: &rusqlite::Connection,
job_uid: &str,
) -> Result<Option<CaptureJobSummary>> {
Ok(
database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
job_uid: r.job_uid,
archive_id: r.archive_id,
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
notes_json: r.notes_json,
created_at: r.created_at,
updated_at: r.updated_at,
}),
)
}
/// Lists all collections in the archive.
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
let records = database::list_collections(conn)?;
Ok(records
.into_iter()
.map(|r| CollectionSummary {
collection_uid: r.collection_uid,
name: r.name,
slug: r.slug,
default_visibility_bits: r.default_visibility_bits,
created_at: r.created_at,
})
.collect())
}
/// Represents an entry's membership in a collection with its visibility bits.
#[derive(Debug, Clone, serde::Serialize)]
pub struct EntryCollectionMembership {
pub collection_uid: String,
pub visibility_bits: u32,
}
/// Returns collection memberships for the given entry_uid.
/// Returns Ok(None) if the entry_uid does not exist.
pub fn get_entry_collections(
conn: &rusqlite::Connection,
entry_uid: &str,
) -> Result<Option<Vec<EntryCollectionMembership>>> {
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 memberships = database::get_entry_collection_memberships(conn, entry_id)?;
Ok(Some(
memberships
.into_iter()
.map(|(_, uid, bits)| EntryCollectionMembership {
collection_uid: uid,
visibility_bits: bits,
})
.collect(),
))
}
/// Returns entries belonging to a collection, filtered by caller_bits visibility.
/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits.
pub fn list_entries_for_collection(
conn: &rusqlite::Connection,
collection_id: i64,
caller_bits: u32,
) -> Result<Vec<EntrySummary>> {
let sql = format!(
"{} {} \
JOIN collection_entries ce ON ce.entry_id = e.id \
WHERE ce.collection_id = ?1 \
AND (CAST(?2 AS INTEGER) & 12 != 0 \
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
GROUP BY e.id \
ORDER BY e.archived_at DESC, e.id DESC",
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt
.query_map([collection_id, caller_bits as i64], |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)?,
has_favicon: row.get::<_, i64>(10)? != 0,
cached_bytes: row.get(11)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// 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<PathBuf> {
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)]
pub struct SearchEntriesQuery {
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
pub q: Option<String>,
/// Exact match on e.source_kind
pub source_kind: Option<String>,
/// Exact match on e.entity_kind
pub entity_kind: Option<String>,
/// LIKE-matched against si.canonical_url
pub url: Option<String>,
/// LIKE-matched against e.title
pub title: Option<String>,
/// e.archived_at >= after (inclusive, ISO 8601)
pub after: Option<String>,
/// e.archived_at < before (exclusive, ISO 8601)
pub before: Option<String>,
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
pub tag: Option<String>,
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
pub caller_bits: u32,
}
impl Default for SearchEntriesQuery {
fn default() -> Self {
Self {
q: None,
source_kind: None,
entity_kind: None,
url: None,
title: None,
after: None,
before: None,
tag: None,
caller_bits: u32::MAX,
}
}
}
/// 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<SearchEntriesQuery, String> {
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, \
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
e.cached_bytes";
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<Vec<EntrySummary>> {
let mut params: Vec<String> = 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());
}
// Visibility filter
let n = params.len() + 1;
sql.push_str(&format!(
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
OR EXISTS (SELECT 1 FROM collection_entries ce \
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
));
params.push(query.caller_bits.to_string());
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)?,
has_favicon: row.get::<_, i64>(10)? != 0,
cached_bytes: row.get(11)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result<Tag> {
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<Tag> {
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.
/// Each node includes a direct entry count and a subtree count (unique entries
/// assigned to the tag itself or any descendant).
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
use std::collections::HashMap;
let records = database::list_all_tags(conn)?;
// Fetch direct entry counts for all tags in a single query.
let mut counts: HashMap<String, i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"SELECT t.tag_uid, COUNT(eta.entry_id) \
FROM tags t \
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = t.id \
GROUP BY t.id",
)?;
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
let (uid, cnt) = row?;
counts.insert(uid, cnt);
}
}
// Fetch subtree entry counts: for each tag, count distinct entries assigned
// to it or any descendant. COUNT(DISTINCT) ensures an entry tagged at both
// a parent and a child is counted only once.
let mut subtree_counts: HashMap<String, i64> = HashMap::new();
{
let mut stmt = conn.prepare(
"WITH RECURSIVE descendants(ancestor_id, descendant_id) AS ( \
SELECT id, id FROM tags \
UNION ALL \
SELECT d.ancestor_id, t.id \
FROM tags t JOIN descendants d ON t.parent_tag_id = d.descendant_id \
) \
SELECT t.tag_uid, COUNT(DISTINCT eta.entry_id) \
FROM tags t \
JOIN descendants d ON d.ancestor_id = t.id \
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = d.descendant_id \
GROUP BY t.id",
)?;
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
let (uid, cnt) = row?;
subtree_counts.insert(uid, cnt);
}
}
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
for record in records {
by_parent
.entry(record.parent_tag_id)
.or_default()
.push(record);
}
fn build_nodes(
parent_id: Option<i64>,
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
counts: &HashMap<String, i64>,
subtree_counts: &HashMap<String, i64>,
) -> Vec<TagNode> {
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(),
},
entry_count: counts.get(&r.tag_uid).copied().unwrap_or(0),
subtree_count: subtree_counts.get(&r.tag_uid).copied().unwrap_or(0),
children: build_nodes(Some(r.id), by_parent, counts, subtree_counts),
})
.collect()
}
Ok(build_nodes(None, &by_parent, &counts, &subtree_counts))
}
/// 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<Option<Vec<Tag>>> {
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<Option<Tag>> {
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<bool> {
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<Vec<EntrySummary>> {
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,
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon,
e.cached_bytes
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)?,
has_favicon: row.get::<_, i64>(10)? != 0,
cached_bytes: row.get(11)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
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, u32::MAX).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, u32::MAX).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<i64>,
root_entry_id: Option<i64>,
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 tag_tree_entry_counts_direct_and_subtree() {
let (conn, user_id, run_id) = make_tag_test_db();
// Tree: /science -> /science/cs -> /science/cs/algorithms
create_tag(&conn, "/science/cs/algorithms").unwrap();
let e1 = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E1",
"https://example.com/e1",
);
let e2 = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E2",
"https://example.com/e2",
);
// e1 → /science/cs/algorithms (leaf), e2 → /science (root)
assign_entry_tag(&conn, &e1.entry_uid, "/science/cs/algorithms").unwrap();
assign_entry_tag(&conn, &e2.entry_uid, "/science").unwrap();
let tree = list_tag_tree(&conn).unwrap();
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
let cs = science
.children
.iter()
.find(|n| n.tag.slug == "cs")
.unwrap();
let algo = cs
.children
.iter()
.find(|n| n.tag.slug == "algorithms")
.unwrap();
assert_eq!(science.entry_count, 1, "science direct = 1");
assert_eq!(
science.subtree_count, 2,
"science subtree = 2 (e1 via algo, e2 direct)"
);
assert_eq!(cs.entry_count, 0, "cs direct = 0");
assert_eq!(cs.subtree_count, 1, "cs subtree = 1 (e1 via algo)");
assert_eq!(algo.entry_count, 1, "algo direct = 1");
assert_eq!(algo.subtree_count, 1, "algo subtree = 1");
}
#[test]
fn tag_tree_subtree_count_deduplicates_shared_entry() {
// Regression: an entry assigned to both a parent and a child must count
// as 1 in the parent's subtree_count, not 2.
let (conn, user_id, run_id) = make_tag_test_db();
create_tag(&conn, "/science/cs").unwrap();
let e = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"E",
"https://example.com/ded",
);
assign_entry_tag(&conn, &e.entry_uid, "/science").unwrap();
assign_entry_tag(&conn, &e.entry_uid, "/science/cs").unwrap();
let tree = list_tag_tree(&conn).unwrap();
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
assert_eq!(
science.subtree_count, 1,
"entry assigned to both parent and child must not be double-counted"
);
assert_eq!(science.entry_count, 1, "science direct = 1");
assert_eq!(science.children[0].subtree_count, 1, "cs subtree = 1");
}
#[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);
}
}