diff --git a/AGENTS.md b/AGENTS.md index 01016dd..687a2eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too ./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction, # recurrence, "what is next?" ranking, op-log/HLC/CRDT sync ./crates/hephd/ # daemon: local mode done (JSON-RPC over unix socket + file lock); server/client modes planned -./crates/heph/ # CLI (planned): export, scripting, `heph conflicts` +./crates/heph/ # CLI: next/task/doc/get/export (thin client of hephd); `heph conflicts` planned ./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim ./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes diff --git a/Cargo.lock b/Cargo.lock index 20bf208..35fc93e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,19 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heph" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "heph-core", + "hephd", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "heph-core" version = "0.0.0" @@ -350,6 +363,7 @@ dependencies = [ "rrule", "rusqlite", "serde", + "tempfile", "thiserror 2.0.18", "ulid", ] diff --git a/Cargo.toml b/Cargo.toml index 8e20550..d3a6d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/heph-core", "crates/hephd"] +members = ["crates/heph-core", "crates/hephd", "crates/heph"] [workspace.package] edition = "2021" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index 3cb94c7..718de84 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -19,3 +19,4 @@ serde.workspace = true [dev-dependencies] proptest = "1" +tempfile = "3" diff --git a/crates/heph-core/src/error.rs b/crates/heph-core/src/error.rs index e69957b..79cf597 100644 --- a/crates/heph-core/src/error.rs +++ b/crates/heph-core/src/error.rs @@ -7,6 +7,10 @@ pub enum Error { #[error("sqlite: {0}")] Sqlite(#[from] rusqlite::Error), + /// A filesystem failure (e.g. during `export`). + #[error("io: {0}")] + Io(#[from] std::io::Error), + /// The DB file is already locked by another `local`/`server` process. #[error("store is already locked by another process: {0}")] Locked(String), diff --git a/crates/heph-core/src/export.rs b/crates/heph-core/src/export.rs new file mode 100644 index 0000000..2173cc1 --- /dev/null +++ b/crates/heph-core/src/export.rs @@ -0,0 +1,185 @@ +//! Export — materialize the store as a directory tree of `.md` files +//! (tech-spec §5). +//! +//! A faithful, **one-way** portable snapshot: each non-tombstoned node becomes +//! `/.md` with YAML frontmatter (id, kind, title, timestamps, task +//! scalars, aliases, outgoing links) plus its markdown body. There is no import +//! in v1 — SQLite remains the source of truth. + +use std::fmt::Write as _; + +use crate::model::{Link, Node, Task}; + +/// One file to write during export. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportFile { + /// Path relative to the export root (e.g. `task/01ARZ….md`). + pub path: String, + /// Full file contents (frontmatter + body). + pub content: String, +} + +/// Everything needed to render one node's export file. +pub struct NodeExport<'a> { + /// The node itself. + pub node: &'a Node, + /// Its task scalars, if it is a task. + pub task: Option<&'a Task>, + /// Its aliases (wiki-link names), if any. + pub aliases: &'a [String], + /// Its non-tombstoned outgoing links. + pub links: &'a [Link], +} + +/// The relative path for a node's export file: `/.md`. +pub fn file_path(node: &Node) -> String { + format!("{}/{}.md", node.kind.as_str(), node.id) +} + +/// Render a node to its export file (frontmatter + body). +pub fn render(export: &NodeExport) -> ExportFile { + let n = export.node; + let mut fm = String::new(); + let _ = writeln!(fm, "---"); + let _ = writeln!(fm, "id: {}", n.id); + let _ = writeln!(fm, "kind: {}", n.kind.as_str()); + let _ = writeln!(fm, "title: {}", yaml_string(&n.title)); + let _ = writeln!(fm, "created_at: {}", n.created_at); + let _ = writeln!(fm, "modified_at: {}", n.modified_at); + + if let Some(task) = export.task { + let _ = writeln!(fm, "state: {}", task.state.as_str()); + if let Some(a) = task.attention { + let _ = writeln!(fm, "attention: {}", a.as_str()); + } + if let Some(d) = task.do_date { + let _ = writeln!(fm, "do_date: {d}"); + } + if let Some(l) = task.late_on { + let _ = writeln!(fm, "late_on: {l}"); + } + if let Some(r) = &task.recurrence { + let _ = writeln!(fm, "recurrence: {}", yaml_string(r)); + } + } + + if !export.aliases.is_empty() { + let _ = writeln!(fm, "aliases:"); + for alias in export.aliases { + let _ = writeln!(fm, " - {}", yaml_string(alias)); + } + } + + if !export.links.is_empty() { + let _ = writeln!(fm, "links:"); + for link in export.links { + let _ = writeln!( + fm, + " - {{ type: {}, dst: {} }}", + link.link_type.as_str(), + link.dst_id + ); + } + } + + let _ = writeln!(fm, "---"); + + let body = n.body.as_deref().unwrap_or(""); + let mut content = fm; + content.push_str(body); + if !content.ends_with('\n') { + content.push('\n'); + } + + ExportFile { + path: file_path(n), + content, + } +} + +/// Quote a scalar for YAML when needed; always quoting is safe and simplest. +fn yaml_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Attention, LinkType, NodeKind, TaskState}; + + fn node(kind: NodeKind, id: &str, title: &str, body: Option<&str>) -> Node { + Node { + id: id.into(), + owner_id: "u".into(), + kind, + title: title.into(), + body: body.map(str::to_string), + created_at: 100, + modified_at: 200, + hlc: "0".into(), + tombstoned: false, + } + } + + #[test] + fn doc_renders_frontmatter_and_body() { + let n = node(NodeKind::Doc, "D1", "Roof log", Some("# Roof\n\nnotes")); + let f = render(&NodeExport { + node: &n, + task: None, + aliases: &[], + links: &[], + }); + assert_eq!(f.path, "doc/D1.md"); + assert!(f.content.starts_with("---\nid: D1\nkind: doc\n")); + assert!(f.content.contains("title: \"Roof log\"\n")); + assert!(f.content.ends_with("# Roof\n\nnotes\n")); + } + + #[test] + fn task_renders_scalars_and_links() { + let n = node(NodeKind::Task, "T1", "Fix roof", None); + let task = Task { + node_id: "T1".into(), + attention: Some(Attention::Orange), + do_date: Some(555), + late_on: None, + state: TaskState::Outstanding, + recurrence: Some("FREQ=DAILY".into()), + }; + let links = vec![Link { + id: "L1".into(), + src_id: "T1".into(), + dst_id: "D9".into(), + link_type: LinkType::CanonicalContext, + created_at: 1, + tombstoned: false, + }]; + let f = render(&NodeExport { + node: &n, + task: Some(&task), + aliases: &[], + links: &links, + }); + assert!(f.content.contains("state: outstanding\n")); + assert!(f.content.contains("attention: orange\n")); + assert!(f.content.contains("do_date: 555\n")); + assert!(!f.content.contains("late_on")); + assert!(f.content.contains("recurrence: \"FREQ=DAILY\"\n")); + assert!(f + .content + .contains("- { type: canonical-context, dst: D9 }\n")); + } + + #[test] + fn title_quotes_are_escaped() { + let n = node(NodeKind::Doc, "D2", "She said \"hi\"", Some("")); + let f = render(&NodeExport { + node: &n, + task: None, + aliases: &[], + links: &[], + }); + assert!(f.content.contains(r#"title: "She said \"hi\"""#)); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 40d431a..d3c2b15 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod clock; pub mod error; +pub mod export; pub mod extract; pub mod model; pub mod ranking; @@ -19,6 +20,7 @@ pub mod store; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; +pub use export::{render as render_export, ExportFile, NodeExport}; pub use extract::{extract, ContextItem, Extraction}; pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use ranking::{rank, Dimension, RankedTask, RANKING}; diff --git a/crates/heph-core/src/sqlite/exporter.rs b/crates/heph-core/src/sqlite/exporter.rs new file mode 100644 index 0000000..02b0bbe --- /dev/null +++ b/crates/heph-core/src/sqlite/exporter.rs @@ -0,0 +1,51 @@ +//! `export` — write the store to a directory tree of `.md` files (tech-spec §5). + +use std::fs; +use std::path::Path; + +use rusqlite::Connection; + +use super::{links, nodes, tasks}; +use crate::error::Result; +use crate::export::{render, NodeExport}; +use crate::model::NodeKind; + +/// Materialize every non-tombstoned node for `owner` under `dir`, returning the +/// count written. One-way snapshot; SQLite stays the source of truth. +pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result { + let ids: Vec = { + let mut stmt = conn.prepare( + "SELECT id FROM nodes WHERE owner_id = ?1 AND tombstoned = 0 ORDER BY created_at, id", + )?; + let rows = stmt.query_map([owner], |r| r.get(0))?; + rows.collect::>>()? + }; + + let mut count = 0; + for id in ids { + let Some(node) = nodes::get(conn, &id)? else { + continue; + }; + let task = if node.kind == NodeKind::Task { + tasks::get(conn, &id)? + } else { + None + }; + let aliases = nodes::aliases(conn, &id)?; + let outgoing = links::outgoing(conn, &id)?; + + let file = render(&NodeExport { + node: &node, + task: task.as_ref(), + aliases: &aliases, + links: &outgoing, + }); + let path = dir.join(&file.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, file.content)?; + count += 1; + } + Ok(count) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 323304b..f29b3f0 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -9,6 +9,7 @@ //! as free functions over a `&Connection`; the [`Store`] impl here is a thin //! delegating layer so a transaction can span several of them. +mod exporter; mod links; mod log; mod migrations; @@ -177,6 +178,10 @@ impl Store for LocalStore { fn log_tail(&self, task_id: &str, n: usize) -> Result> { log::tail(&self.conn, task_id, n) } + + fn export(&self, dir: &std::path::Path) -> Result { + exporter::export(&self.conn, &self.owner_id, dir) + } } #[cfg(test)] diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 1f68eac..04ee6a1 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -136,6 +136,13 @@ pub(super) fn update( Ok(node) } +/// A node's aliases (wiki-link names), sorted. Empty until aliases are written. +pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; + let rows = stmt.query_map([id], |r| r.get(0))?; + Ok(rows.collect::>>()?) +} + /// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge /// monotonic (tech-spec §4.3). pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> { diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 260c60a..6b251e2 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -82,4 +82,8 @@ pub trait Store { /// The task's latest `n` log entries (oldest→newest); empty if it has none. fn log_tail(&self, task_id: &str, n: usize) -> Result>; + + /// 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; } diff --git a/crates/heph-core/tests/export.rs b/crates/heph-core/tests/export.rs new file mode 100644 index 0000000..f0f0f60 --- /dev/null +++ b/crates/heph-core/tests/export.rs @@ -0,0 +1,58 @@ +//! `Store::export` writes a faithful .md tree (tech-spec §5, slice 7). + +use heph_core::{FixedClock, LocalStore, NewNode, NewTask, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn export_writes_a_file_per_node_with_frontmatter_and_body() { + let dir = tempfile::tempdir().unwrap(); + let mut s = store(); + + let doc = s + .create_node(NewNode::doc("Roof log", "# Roof\n\nCalled contractor.")) + .unwrap(); + let task = s + .create_task(NewTask { + title: "Fix roof".into(), + ..Default::default() + }) + .unwrap(); + + // task.create also makes a canonical context doc → 3 nodes total. + let count = s.export(dir.path()).unwrap(); + assert_eq!(count, 3); + + // The doc file exists with frontmatter and its body. + let doc_file = dir.path().join(format!("doc/{}.md", doc.id)); + let doc_text = std::fs::read_to_string(&doc_file).unwrap(); + assert!(doc_text.starts_with("---\n")); + assert!(doc_text.contains(&format!("id: {}\n", doc.id))); + assert!(doc_text.contains("kind: doc\n")); + assert!(doc_text.contains("title: \"Roof log\"\n")); + assert!(doc_text.ends_with("# Roof\n\nCalled contractor.\n")); + + // The task file carries its scalars and the canonical-context link. + let task_file = dir.path().join(format!("task/{}.md", task.node_id)); + let task_text = std::fs::read_to_string(&task_file).unwrap(); + assert!(task_text.contains("kind: task\n")); + assert!(task_text.contains("state: outstanding\n")); + assert!(task_text.contains("type: canonical-context")); +} + +#[test] +fn export_excludes_tombstoned_nodes() { + let dir = tempfile::tempdir().unwrap(); + let mut s = store(); + + let keep = s.create_node(NewNode::doc("Keep", "kept")).unwrap(); + let gone = s.create_node(NewNode::doc("Gone", "gone")).unwrap(); + s.tombstone_node(&gone.id).unwrap(); + + let count = s.export(dir.path()).unwrap(); + assert_eq!(count, 1); + assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists()); + assert!(!dir.path().join(format!("doc/{}.md", gone.id)).exists()); +} diff --git a/crates/heph/Cargo.toml b/crates/heph/Cargo.toml new file mode 100644 index 0000000..dabed91 --- /dev/null +++ b/crates/heph/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "heph" +description = "Hephaestus CLI: a thin client of the local hephd daemon (utility/admin surface)." +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "heph" +path = "src/main.rs" + +[dependencies] +heph-core = { path = "../heph-core" } +hephd = { path = "../hephd" } +clap.workspace = true +serde_json.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tempfile = "3" +tokio.workspace = true diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs new file mode 100644 index 0000000..3ebb54c --- /dev/null +++ b/crates/heph/src/main.rs @@ -0,0 +1,158 @@ +//! `heph` — the CLI surface (tech-spec §1). A thin client of the local +//! `hephd`: it never touches SQLite, only the daemon socket. Secondary to +//! `heph.nvim`; for scripting, admin, smoke tests, and `export`. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use serde_json::{json, Value}; + +use heph_core::{Node, RankedTask, Task}; +use hephd::{default_socket_path, Client}; + +#[derive(Parser, Debug)] +#[command(name = "heph", version, about)] +struct Cli { + /// Path to the hephd unix socket (defaults to the standard runtime path). + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Show the Tactical "what is next?" ranking. + Next { + /// Restrict to a project node id. + #[arg(long)] + scope: Option, + /// Maximum rows (red items always show). + #[arg(long, default_value_t = 5)] + limit: usize, + }, + /// Create a committed task (auto-creates its canonical context doc). + Task { + /// The task title. + title: String, + /// Attention-state: white|orange|red|blue. + #[arg(long)] + attention: Option, + /// Earliest-actionable date, epoch ms. + #[arg(long)] + do_date: Option, + /// Lateness-problem marker, epoch ms. + #[arg(long)] + late_on: Option, + /// Project node id to file it under. + #[arg(long)] + project: Option, + /// RFC-5545 RRULE for a recurring task. + #[arg(long)] + recurrence: Option, + }, + /// Create a document node. + Doc { + /// The document title. + title: String, + /// Markdown body. + #[arg(long)] + body: Option, + }, + /// Fetch a node by id and print it as JSON. + Get { + /// Node id. + id: String, + }, + /// Export the store to a directory tree of .md files. + Export { + /// Destination directory (created if needed). + dir: PathBuf, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = cli.socket.unwrap_or_else(default_socket_path); + let mut client = Client::connect(&socket)?; + + match cli.command { + Command::Next { scope, limit } => { + let result = client.call("next", json!({ "scope": scope, "limit": limit }))?; + let tasks: Vec = serde_json::from_value(result)?; + if tasks.is_empty() { + println!("Nothing actionable right now."); + } + for t in &tasks { + println!("{}", format_row(t)); + } + } + Command::Task { + title, + attention, + do_date, + late_on, + project, + recurrence, + } => { + let result = client.call( + "task.create", + json!({ + "title": title, + "attention": attention, + "do_date": do_date, + "late_on": late_on, + "project_id": project, + "recurrence": recurrence, + }), + )?; + let task: Task = serde_json::from_value(result)?; + println!("Created task {} \"{title}\"", task.node_id); + } + Command::Doc { title, body } => { + let result = client.call( + "node.create", + json!({ "kind": "doc", "title": title, "body": body }), + )?; + let node: Node = serde_json::from_value(result)?; + println!("Created doc {} \"{}\"", node.id, node.title); + } + Command::Get { id } => { + let result = client.call("node.get", json!({ "id": id }))?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + Command::Export { dir } => { + let path = dir + .to_str() + .context("export path is not valid UTF-8")? + .to_string(); + let result = client.call("export", json!({ "path": path }))?; + let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); + println!("Exported {count} nodes to {}", dir.display()); + } + } + Ok(()) +} + +/// One concise Tactical row: attention tag, title, and do/late context. +fn format_row(t: &RankedTask) -> String { + let tag = t + .attention + .map(|a| format!("[{}]", serde_json::to_value(a).unwrap().as_str().unwrap())) + .unwrap_or_else(|| "[ ]".to_string()); + let mut extra = Vec::new(); + if let Some(d) = t.do_date { + extra.push(format!("do:{d}")); + } + if let Some(l) = t.late_on { + extra.push(format!("late:{l}")); + } + let suffix = if extra.is_empty() { + String::new() + } else { + format!(" ({})", extra.join(", ")) + }; + format!("{tag} {}{suffix}", t.title) +} diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs new file mode 100644 index 0000000..8a552b4 --- /dev/null +++ b/crates/heph/tests/cli.rs @@ -0,0 +1,97 @@ +//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd` +//! over a unix socket, and assert output + side effects (export files). + +use std::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use tokio::net::UnixListener; + +use heph_core::{FixedClock, LocalStore}; +use hephd::Daemon; + +const NOW: i64 = 1_704_067_200_000; + +/// Spawn a daemon on its own thread+runtime; return (socket, tempdir). +fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("heph.db"); + let socket = dir.path().join("d.sock"); + + let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); + let socket_for_thread = socket.clone(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = UnixListener::bind(&socket_for_thread).unwrap(); + let _ = Daemon::new(store).serve(listener).await; + }); + }); + for _ in 0..200 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + (socket, dir) +} + +/// Run the `heph` binary against `socket`; return (stdout, success). +fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) { + let out = Command::new(env!("CARGO_BIN_EXE_heph")) + .arg("--socket") + .arg(socket) + .args(args) + .output() + .expect("run heph"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.success(), + ) +} + +#[test] +fn task_then_next_shows_the_task() { + let (socket, _dir) = spawn_daemon(); + + let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]); + assert!(ok, "task create failed: {out}"); + assert!(out.contains("Created task"), "{out}"); + + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!(out.contains("[red]"), "{out}"); + assert!(out.contains("Buy milk"), "{out}"); +} + +#[test] +fn next_on_empty_store_is_friendly() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["next"]); + assert!(ok); + assert!(out.contains("Nothing actionable"), "{out}"); +} + +#[test] +fn export_writes_markdown_files() { + let (socket, dir) = spawn_daemon(); + heph(&socket, &["doc", "Roof log", "--body", "# Roof"]); + + let export_dir = dir.path().join("export"); + let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]); + assert!(ok, "export failed: {out}"); + assert!(out.contains("Exported 1 nodes"), "{out}"); + + // The doc landed as a .md file under doc/. + let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc")) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(docs.len(), 1); + let text = std::fs::read_to_string(docs[0].path()).unwrap(); + assert!(text.contains("title: \"Roof log\""), "{text}"); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index ee87c7c..981388b 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -7,6 +7,8 @@ //! **mode-agnostic**: Tactical/Strategic/Organizational are plugin-side //! compositions of these primitives, not daemon concepts. +use std::path::Path; + use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -154,6 +156,11 @@ struct LogTailParams { n: Option, } +#[derive(Deserialize)] +struct ExportParams { + path: String, +} + /// Default `next`/`list` result size (tech-spec §6). const DEFAULT_LIMIT: usize = 5; /// Default `log.tail` size. @@ -217,6 +224,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ExportParams = parse(params)?; + let count = store.export(Path::new(&p.path))?; + json!({ "count": count }) + } other => { return Err(RpcError::new( METHOD_NOT_FOUND, diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index c0a213f..f1275ba 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -6,4 +6,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. - Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. - `hephd` daemon, local mode (§3, §6): exclusive file lock (handoff-ready), line-delimited JSON-RPC over a unix socket exposing the node/task/next/links/log methods, with DB work on tokio's blocking pool. Synchronous client for surfaces/CLI. Model types are serde-serializable. +- `heph` CLI (§1) — a thin client of the daemon: `next`, `task`, `doc`, `get`, `export`. Export materializes the store to a `/.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded. - CI runs the Rust suite (fmt/clippy/test) via the project build hook.