feat(cli): heph project list (+ node.list RPC)
Some checks failed
Build / validate (pull_request) Has been cancelled

Add a list-by-kind primitive so projects (and later tags) can be enumerated.

- core: Store::list_nodes(kind?) — owner-scoped, non-tombstoned, title-sorted;
  sqlite nodes::list; LocalStore/RemoteStore impls
- rpc: node.list {kind?} dispatch
- cli: `heph project list`
- tests: core list_nodes (kind filter, case-insensitive sort, tombstone
  exclusion) + cli project_list (projects only, not tasks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 19:50:19 -07:00
commit f122c9e6a4
8 changed files with 101 additions and 4 deletions

View file

@ -30,7 +30,7 @@ use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::hlc::Hlc;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
@ -277,6 +277,10 @@ impl Store for LocalStore {
nodes::search(&self.conn, &self.owner_id, query)
}
fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>> {
nodes::list(&self.conn, &self.owner_id, kind)
}
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
let now = self.clock.now_ms();
nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date)

View file

@ -332,6 +332,23 @@ pub(super) fn search(conn: &Connection, owner: &str, query: &str) -> Result<Vec<
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// List non-tombstoned, owner-scoped nodes, optionally filtered by `kind`,
/// ordered by title (case-insensitive). Used by surfaces to enumerate projects,
/// tags, etc. (tech-spec §6 `node.list`).
pub(super) fn list(conn: &Connection, owner: &str, kind: Option<NodeKind>) -> Result<Vec<Node>> {
let mut sql = format!("SELECT {COLUMNS} FROM nodes WHERE owner_id = ?1 AND tombstoned = 0");
if kind.is_some() {
sql.push_str(" AND kind = ?2");
}
sql.push_str(" ORDER BY title COLLATE NOCASE");
let mut stmt = conn.prepare(&sql)?;
let rows = match kind {
Some(k) => stmt.query_map((owner, k.as_str()), from_row)?,
None => stmt.query_map([owner], from_row)?,
};
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// A node's aliases (wiki-link names), sorted. Empty until aliases are written.
pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?;

View file

@ -6,7 +6,7 @@
use crate::error::Result;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
@ -40,6 +40,11 @@ pub trait Store {
/// 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>>;
/// 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).

View file

@ -6,6 +6,36 @@ fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn list_nodes_filters_by_kind_sorts_by_title_and_excludes_tombstoned() {
let mut s = store();
let proj = |s: &mut LocalStore, t: &str| {
s.create_node(NewNode {
kind: NodeKind::Project,
title: t.into(),
body: None,
})
.unwrap()
.id
};
proj(&mut s, "Maintenance");
proj(&mut s, "child"); // lowercase → tests case-insensitive sort
let gone = proj(&mut s, "Archived");
s.create_node(NewNode::doc("Just a doc", "not a project"))
.unwrap();
s.tombstone_node(&gone).unwrap();
// kind=project excludes the doc and the tombstoned project, title-sorted.
let projects = s.list_nodes(Some(NodeKind::Project)).unwrap();
let titles: Vec<&str> = projects.iter().map(|n| n.title.as_str()).collect();
assert_eq!(titles, vec!["child", "Maintenance"]);
// No filter returns every non-tombstoned node (incl. the doc).
let all = s.list_nodes(None).unwrap();
assert!(all.iter().any(|n| n.title == "Just a doc"));
assert!(!all.iter().any(|n| n.id == gone), "tombstoned excluded");
}
#[test]
fn search_matches_title_and_body() {
let mut s = store();

View file

@ -270,6 +270,8 @@ enum ProjectAction {
#[arg(long)]
parent: Option<String>,
},
/// List all projects.
List,
}
#[derive(Subcommand, Debug)]
@ -582,6 +584,16 @@ fn main() -> Result<()> {
}
println!("Created project {} \"{}\"", node.id, node.title);
}
ProjectAction::List => {
let result = client.call("node.list", json!({ "kind": "project" }))?;
let nodes: Vec<Node> = serde_json::from_value(result)?;
if nodes.is_empty() {
println!("No projects.");
}
for n in &nodes {
println!("{} {}", n.id, n.title);
}
}
},
Command::Sync { status } => {
let method = if status { "sync.status" } else { "sync.now" };

View file

@ -199,6 +199,21 @@ fn project_add_then_file_a_task_under_it() {
assert!(!ok, "expected failure for unknown project: {out}");
}
#[test]
fn project_list_shows_projects_only() {
let (socket, _dir) = spawn_daemon();
heph(&socket, &["project", "add", "Maintenance"]);
heph(&socket, &["project", "add", "Coding"]);
// A task (and its context doc) must not show up in the project list.
heph(&socket, &["task", "Some task"]);
let (out, ok) = heph(&socket, &["project", "list"]);
assert!(ok, "{out}");
assert!(out.contains("Maintenance"), "{out}");
assert!(out.contains("Coding"), "{out}");
assert!(!out.contains("Some task"), "tasks are not projects: {out}");
}
#[test]
fn log_append_then_tail() {
let (socket, _dir) = spawn_daemon();

View file

@ -17,7 +17,7 @@ use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use heph_core::{
Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result,
Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Result,
SchedulePatch, Store, SyncCursors, Task, TaskState,
};
@ -209,6 +209,10 @@ impl Store for RemoteStore {
self.call_as("search", json!({ "query": query }))
}
fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>> {
self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) }))
}
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
self.call_as("journal.open_or_create", json!({ "date": date }))
}

View file

@ -13,7 +13,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState};
use heph_core::{Attention, LinkType, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState};
/// A JSON-RPC request line.
#[derive(Debug, Deserialize)]
@ -114,6 +114,12 @@ struct ResolveParams {
title: String,
}
#[derive(Deserialize)]
struct NodeListParams {
#[serde(default)]
kind: Option<NodeKind>,
}
#[derive(Deserialize)]
struct UpdateParams {
id: String,
@ -245,6 +251,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: ResolveParams = parse(params)?;
json!(store.resolve_node(&p.title)?)
}
"node.list" => {
let p: NodeListParams = parse(params)?;
json!(store.list_nodes(p.kind)?)
}
"task.create" => {
let p: NewTask = parse(params)?;
json!(store.create_task(p)?)