hephaestus/crates/heph-core/src/store.rs
Erich Blume fc25f6ac51 feat: --project arg is case-insensitive / prefix-fuzzy when unambiguous
The `--project <name>` argument matched titles case-sensitively and
exactly, so `--project hephaestus` or `--project heph` failed against a
`Hephaestus` project. Make project-name resolution forgiving but
deterministic, via a tiered match in `resolve_project_id`:

  1. exact (case-sensitive) — the historical behavior; always wins
  2. case-insensitive exact — only when unambiguous
  3. case-insensitive prefix — only when unambiguous

Ambiguous fuzzy matches resolve to None (callers report "no project
named X") rather than silently picking one. This single resolver already
backed `heph list --project` (via project_scope); route the CLI's
task/edit/promote/parent path through it too with a new `project.resolve`
RPC + `Store::resolve_project`, so every `--project` surface behaves the
same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:57:37 -07:00

244 lines
12 KiB
Rust

//! The storage abstraction (tech-spec §3.1).
//!
//! A runtime points at *something that stores nodes*; whether that is a local
//! SQLite file ([`crate::sqlite::LocalStore`]) or a remote server (a future
//! `RemoteStore`) is configuration. This trait is the seam.
use crate::error::Result;
use crate::filter::ListFilter;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
/// A backend that can store and retrieve nodes, tasks, and links.
///
/// Methods that mutate take `&mut self`: a `LocalStore` holds an exclusive lock
/// on its file, so single-writer semantics are honest at the type level.
pub trait Store {
// --- nodes ---
/// Create a node, assigning it an id and timestamps. Returns the stored row.
fn create_node(&mut self, input: NewNode) -> Result<Node>;
/// Fetch a node by id. Returns `None` if it does not exist.
///
/// Tombstoned nodes are still returned here (callers that must exclude them
/// — `next`, `list`, `search`, `export` — filter explicitly).
fn get_node(&self, id: &str) -> Result<Option<Node>>;
/// Update a node's title and/or body. A body update re-runs markdown
/// extraction and reconciles this node's `wiki` links (tech-spec §5, §6).
fn update_node(
&mut self,
id: &str,
title: Option<String>,
body: Option<String>,
) -> Result<Node>;
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
fn tombstone_node(&mut self, id: &str) -> Result<()>;
/// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`,
/// ordered by title. The enumeration surfaces (projects, tags) build on this
/// (tech-spec §6 `node.list`).
fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>;
/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes
/// minus the internal noise — `tag` nodes and the `doc`s that are a task's
/// canonical-context or log attachment. Backs the `[[` picker.
fn list_linkable_nodes(&self) -> Result<Vec<Node>>;
/// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an
/// alias match first, then an exact, owner-scoped, non-tombstoned title
/// match; `None` if nothing matches (an unresolved link is allowed, §5).
///
/// This is the same mapping the store uses to materialize `wiki` links, so
/// a surface's "follow link under cursor" jumps to the *same* node the
/// stored link points at — unlike fuzzy `search` (tech-spec §6, §8).
fn resolve_node(&self, title: &str) -> Result<Option<Node>>;
// --- tasks ---
/// Create a committed task, auto-creating its canonical context `doc` and
/// the `canonical-context` link (tech-spec §6).
fn create_task(&mut self, input: NewTask) -> Result<Task>;
/// Fetch a task by its node id.
fn get_task(&self, node_id: &str) -> Result<Option<Task>>;
/// Set a task's lifecycle state. Completing a **recurring** task rolls it
/// forward in place — fresh checklist, logged occurrence, advanced do-date
/// (tech-spec §4.4) — rather than marking it done.
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task>;
/// Skip the current occurrence of a recurring task: advance its do-date
/// without logging a completion (tech-spec §4.4). Errors on a non-recurring
/// task.
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task>;
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
/// Apply a partial update to a task's schedule scalars — do-date, late-on,
/// recurrence (tech-spec §6 `task.set_schedule`). Each [`SchedulePatch`]
/// field is a double option: absent = unchanged, `null` = clear, value =
/// set. This is the "reschedule" path (the scalars with no dedicated setter).
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>;
/// Re-file a task under a project (or unfile it when `project_id` is
/// `None`) — the move-to-project path (tech-spec §8.1). OR-set link
/// semantics: the old `in-project` link is tombstoned and a new one added.
/// A given `project_id` must name a live `project`-kind node.
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task>;
/// Delete a project: unfile its tasks (they fall to the Inbox) and tombstone
/// the project node. Tasks are preserved, never deleted.
fn delete_project(&mut self, project_id: &str) -> Result<()>;
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
/// committed task, rewriting that source line into a `[[link]]` to the new
/// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the
/// item among the container's context items in document order.
fn promote(
&mut self,
container_id: &str,
item_ref: usize,
attention: Option<Attention>,
project_id: Option<String>,
) -> Result<Task>;
/// The Tactical "what is next?" ranking (tech-spec §7), using the store's
/// injected clock as `now`. `scope`, when `Some`, restricts to a project
/// node id; `red` items always appear regardless of `limit`.
fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>>;
/// Enumerate outstanding committed tasks matching a [`ListFilter`] — the
/// data-expressed predicate behind filter views (tech-spec §8.2). Returns
/// **titled** [`RankedTask`] rows (the same shape `next` returns, so the
/// survey view needs no N+1 `get_node`). An empty filter is the whole
/// outstanding set (the Organizational survey, tech-spec §6).
fn list(&self, filter: &ListFilter) -> Result<Vec<RankedTask>>;
/// Run a built-in named filter view (`tom|ondeck|chores|work|tasks`,
/// tech-spec §8.2): resolve its project names to ids (subtree-expanded),
/// build the [`ListFilter`], and return the matching rows. Errors on an
/// unknown view name.
fn view(&self, name: &str) -> Result<Vec<RankedTask>>;
/// Resolve a project NAME to its scope ids (the project + its subtree), for
/// `heph list --project <name>`. Errors if the name names no project.
fn project_scope(&self, name: &str) -> Result<Vec<String>>;
/// Resolve a project NAME to its node, restricted to `project`-kind nodes
/// (so a like-named task/doc never wins). Matching is case-insensitive and
/// prefix-fuzzy when unambiguous (an exact title always wins outright).
/// `None` if no project matches. Backs the `--project` CLI argument on
/// `task`/`edit`/`promote` and project-parent resolution.
fn resolve_project(&self, name: &str) -> Result<Option<Node>>;
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>;
/// Full-text search over title + body (FTS5), owner-scoped, best-match
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
fn search(&self, query: &str) -> Result<Vec<Node>>;
/// Open (creating if absent) the journal node for an ISO `date`. The id is
/// deterministic in `(owner, date)` so offline replicas converge (§3.1).
fn journal_open_or_create(&mut self, date: &str) -> Result<Node>;
// --- links ---
/// Add a typed link between two nodes.
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link>;
/// All non-tombstoned links originating at `id`.
fn outgoing_links(&self, id: &str) -> Result<Vec<Link>>;
/// All non-tombstoned links pointing at `id` (backlinks).
fn backlinks(&self, id: &str) -> Result<Vec<Link>>;
// --- tags (tech-spec §4, §8.3) ---
/// Tag `node_id` with `tag` (trimmed), creating the canonical `tag`-kind
/// node on first use — its id is deterministic in `(owner, name)`, so a
/// name is one shared tag and replicas converge. OR-set `tagged` link;
/// idempotent. Returns the tag node. Errors on a missing node or blank name.
fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node>;
/// Remove `tag` from `node_id` (tombstone the `tagged` link); a no-op if it
/// isn't tagged. The tag node itself persists.
fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()>;
/// The tag names on `node_id`, sorted. (Enumerate *all* tags via
/// [`Store::list_nodes`] with [`NodeKind::Tag`].)
fn tags_of(&self, node_id: &str) -> Result<Vec<String>>;
/// One-time migration (tech-spec §8.4): rewrite legacy name-addressed body
/// links `[[Name]]` to the canonical `[[NODEID]]`, resolving each name and
/// re-materializing the `wiki` links by id. Idempotent (already-id links are
/// left alone). Returns the number of nodes whose body changed.
fn migrate_wikilinks_to_ids(&mut self) -> Result<usize>;
// --- per-task log ([[design]] §6.4) ---
/// Append a line to a task's append-only log (creating the log on first
/// use). The log is the resumption breadcrumb store.
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>;
/// The task's latest `n` log entries (oldest→newest); empty if it has none.
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>>;
/// Export every non-tombstoned node to a `.md` directory tree under `dir`,
/// returning the count written (tech-spec §5). One-way; no import.
fn export(&self, dir: &std::path::Path) -> Result<usize>;
// --- sync (op-log) ---
/// Ops for this owner with HLC strictly greater than `after` (None ⇒ all),
/// in causal order — the push cursor for sync (tech-spec §12).
fn ops_since(&self, after: Option<&str>) -> Result<Vec<Op>>;
/// Apply a foreign op with the merge rules (LWW / OR-set / tombstone),
/// idempotently. Returns `true` if newly applied. Ops should be applied in
/// HLC order (tech-spec §12).
fn apply_op(&mut self, op: &Op) -> Result<bool>;
/// Rewrite this replica's `owner_id` to a canonical user id — the one-time,
/// pre-first-sync adoption of tech-spec §13. After this all data is owned by
/// `canonical`; replicas that adopt the same id can sync. (Full adoption,
/// incl. owner-embedded deterministic ids, is refined with auth.)
fn adopt_owner(&mut self, canonical: &str) -> Result<()>;
/// The push/pull HLC cursors for a sync `peer` (the hub url). Defaults to
/// empty cursors when this replica has never synced with `peer` (§12).
fn sync_state(&self, peer: &str) -> Result<SyncCursors>;
/// Record progress with a sync `peer`: advance the `pushed`/`pulled` HLC
/// cursors (each `None` leaves that direction unchanged). Upserts the
/// `sync_state` row (§12).
fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>)
-> Result<()>;
/// Resolve an OIDC `sub` to the `owner_id` it may act as on this store —
/// the **multi-tenancy seam** (tech-spec §13). On first sight, **claim** the
/// store's owner by binding its `oidc_sub`; thereafter resolve only that
/// same `sub`. Returns `Some(owner_id)` when the `sub` owns data here, or
/// `None` when a different identity presented a token (the hub then 403s).
///
/// Today a store hosts exactly one owner, so this resolves to that single
/// owner or `None`. Multi-tenancy (serving N owners from one hub) extends
/// this to a real `sub → owner_id` mapping with per-`sub` provisioning, and
/// the hub scopes each request to the resolved owner — without changing this
/// contract. A hub calls this before serving any op exchange.
fn resolve_owner(&mut self, sub: &str) -> Result<Option<String>>;
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
/// Settle a conflict by the user's choice (`"local"`/`"remote"`).
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
}