generated from eblume/project-template
Slice 7 (tech-spec §1, §5, §9). - Export (heph-core): render each non-tombstoned node to `<kind>/<id>.md` with YAML frontmatter (id, kind, title, timestamps, task scalars, aliases, outgoing links) + body. One-way snapshot; `Store::export` writes the tree; tombstones excluded. Added `export` RPC method and Error::Io. - `heph` CLI (clap): thin client of hephd over the socket — `next` (concise ranked rows), `task`, `doc`, `get`, `export`. Never touches SQLite directly. Tests: 3 export render unit + 2 export round-trip integration + 3 CLI process tests driving the real `heph` binary against a real daemon (task→next, empty-store message, export writes files). 70 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed8c7a733a
commit
739214bd07
17 changed files with 625 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ serde.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
185
crates/heph-core/src/export.rs
Normal file
185
crates/heph-core/src/export.rs
Normal file
|
|
@ -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
|
||||
//! `<kind>/<id>.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: `<kind>/<id>.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\"""#));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
51
crates/heph-core/src/sqlite/exporter.rs
Normal file
51
crates/heph-core/src/sqlite/exporter.rs
Normal file
|
|
@ -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<usize> {
|
||||
let ids: Vec<String> = {
|
||||
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::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Vec<String>> {
|
||||
log::tail(&self.conn, task_id, n)
|
||||
}
|
||||
|
||||
fn export(&self, dir: &std::path::Path) -> Result<usize> {
|
||||
exporter::export(&self.conn, &self.owner_id, dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
|
|
|
|||
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
|
|||
58
crates/heph-core/tests/export.rs
Normal file
58
crates/heph-core/tests/export.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
24
crates/heph/Cargo.toml
Normal file
24
crates/heph/Cargo.toml
Normal file
|
|
@ -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
|
||||
158
crates/heph/src/main.rs
Normal file
158
crates/heph/src/main.rs
Normal file
|
|
@ -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<PathBuf>,
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
/// Earliest-actionable date, epoch ms.
|
||||
#[arg(long)]
|
||||
do_date: Option<i64>,
|
||||
/// Lateness-problem marker, epoch ms.
|
||||
#[arg(long)]
|
||||
late_on: Option<i64>,
|
||||
/// Project node id to file it under.
|
||||
#[arg(long)]
|
||||
project: Option<String>,
|
||||
/// RFC-5545 RRULE for a recurring task.
|
||||
#[arg(long)]
|
||||
recurrence: Option<String>,
|
||||
},
|
||||
/// Create a document node.
|
||||
Doc {
|
||||
/// The document title.
|
||||
title: String,
|
||||
/// Markdown body.
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
},
|
||||
/// 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<RankedTask> = 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)
|
||||
}
|
||||
97
crates/heph/tests/cli.rs
Normal file
97
crates/heph/tests/cli.rs
Normal file
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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<usize>,
|
||||
}
|
||||
|
||||
#[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<Va
|
|||
let p: LogTailParams = parse(params)?;
|
||||
json!(store.log_tail(&p.task_id, p.n.unwrap_or(DEFAULT_TAIL))?)
|
||||
}
|
||||
"export" => {
|
||||
let p: ExportParams = parse(params)?;
|
||||
let count = store.export(Path::new(&p.path))?;
|
||||
json!({ "count": count })
|
||||
}
|
||||
other => {
|
||||
return Err(RpcError::new(
|
||||
METHOD_NOT_FOUND,
|
||||
|
|
|
|||
|
|
@ -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 `<kind>/<id>.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded.
|
||||
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue