hephaestus/crates/heph/src/main.rs
Erich Blume babdb21c0a feat: heph context <task-id> reads/edits a task's context doc by id
Editing a task's canonical-context doc body previously meant looking up
its `canonical_context_id` (e.g. via `heph list --json`) and then
`heph node update <doc-id> --body`. Add a `heph context <task-id>`
command that resolves the canonical-context doc from the task's outgoing
links and:

  * prints the body with no flag,
  * `--body <text>` replaces it (`-` reads stdin, matching `node update`),
  * `--append <text>` adds a blank-line-separated paragraph.

Errors clearly when the id has no canonical-context doc (e.g. a plain
doc node rather than a task). Purely a client-side CLI convenience — no
new RPC.

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

937 lines
32 KiB
Rust

//! `heph` — the CLI surface (tech-spec §1). A thin client of the local
//! `hephd`: it never touches SQLite, only the daemon socket.
//!
//! Per the three-surface model ([[design]] §4), the CLI is the **task
//! capture/scripting** surface and exposes the **complete daemon API** — every
//! RPC method has a command. Structured task fields are flags, with human dates
//! (`--do tomorrow`) and recurrence (`--recur weekly`) parsed in [`datespec`].
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use heph_core::{Node, RankedTask, Task};
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
mod service;
#[derive(Parser, Debug)]
#[command(name = "heph", version = heph_core::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(short = 'a', long)]
attention: Option<String>,
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
#[arg(long)]
do_date: Option<String>,
/// Late-on (the sole urgency marker): same date forms as --do-date.
#[arg(long)]
late_on: Option<String>,
/// Project name to file it under (must already exist).
#[arg(long)]
project: Option<String>,
/// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL
/// ("every 3 days", "every fri").
#[arg(long)]
recur: Option<String>,
/// A raw RFC-5545 RRULE (overrides --recur).
#[arg(long)]
rrule: Option<String>,
},
/// Enumerate the outstanding set (the Organizational survey).
List {
/// Restrict to a project node id.
#[arg(long)]
scope: Option<String>,
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
#[arg(long)]
project: Option<String>,
/// Only this attention-state: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// Hide on-deck (blue) items.
#[arg(long)]
no_blue: bool,
/// Print raw JSON rows (node id, canonical-context id, scalars) for scripting/agents.
#[arg(long)]
json: bool,
},
/// Run a built-in filter view (tech-spec §8.2); omit the name to list views.
View {
/// View name: tom|ondeck|chores|work|tasks. Omit to list all views.
name: Option<String>,
},
/// Mark a task done (recurring tasks roll forward).
Done {
/// Task node id.
id: String,
},
/// Mark a task dropped.
Drop {
/// Task node id.
id: String,
},
/// Skip a recurring task's current occurrence (advance without logging).
Skip {
/// Task node id.
id: String,
},
/// Set a task's attention-state.
Attention {
/// Task node id.
id: String,
/// white|orange|red|blue.
attention: String,
},
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
/// clear a field; omit to leave it unchanged). Also re-attentions / re-files.
Edit {
/// Task node id.
id: String,
/// Do-date or `none`.
#[arg(long)]
do_date: Option<String>,
/// Late-on or `none`.
#[arg(long)]
late_on: Option<String>,
/// Recurrence (preset/NL) or `none`.
#[arg(long)]
recur: Option<String>,
/// A raw RRULE or `none`.
#[arg(long)]
rrule: Option<String>,
/// Set attention: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// Re-file under a project (by name); `none` unfiles the task.
#[arg(long)]
project: Option<String>,
},
/// Promote a context-item line into a committed task.
Promote {
/// The container node id (the doc holding the `- [ ]` line).
container_id: String,
/// 1-based index of the context item to promote (document order).
item_ref: usize,
/// Attention for the new task: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// Project name to file the new task under.
#[arg(long)]
project: Option<String>,
},
/// Show a node (and, if it is a task, its scalars).
Show {
/// Node id.
id: String,
},
/// Append to a task's log (with text) or print its latest entries (without).
Log {
/// Task node id.
id: String,
/// Text to append; if omitted, the latest entries are printed.
text: Vec<String>,
/// How many entries to print (read mode).
#[arg(short = 'n', long, default_value_t = 10)]
n: usize,
},
/// Read or edit a task's canonical-context doc body **by task id** — no
/// manual `canonical_context_id` lookup. With neither flag, prints the body.
Context {
/// Task node id.
id: String,
/// Replace the body with this text (`-` reads from stdin).
#[arg(long)]
body: Option<String>,
/// Append this text to the body (separated by a blank line).
#[arg(long)]
append: Option<String>,
},
/// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …).
Health,
/// Create a document node.
Doc {
/// The document title.
title: String,
/// Markdown body.
#[arg(long)]
body: Option<String>,
},
/// Node operations (update, tombstone).
Node {
#[command(subcommand)]
action: NodeAction,
},
/// Fetch a node by id and print it as JSON.
Get {
/// Node id.
id: String,
},
/// Resolve a wiki-link title to a node (exact, alias-then-title).
Resolve {
/// The title or alias.
title: String,
},
/// Full-text search over titles and bodies.
Search {
/// FTS5 query.
query: String,
},
/// Open (or create) the journal for an ISO date (YYYY-MM-DD).
Journal {
/// The ISO date.
date: String,
},
/// List a node's outgoing links.
Links {
/// Node id.
id: String,
},
/// List a node's backlinks.
Backlinks {
/// Node id.
id: String,
},
/// Link operations (add).
Link {
#[command(subcommand)]
action: LinkAction,
},
/// Project operations (add).
Project {
#[command(subcommand)]
action: ProjectAction,
},
/// Tag operations: add/remove a tag on a node, or list tags.
Tag {
#[command(subcommand)]
action: TagAction,
},
/// Force a sync cycle (or show sync status with --status).
Sync {
/// Show status instead of syncing.
#[arg(long)]
status: bool,
},
/// List merge conflicts, or resolve one.
Conflicts {
#[command(subcommand)]
action: Option<ConflictAction>,
},
/// Export the store to a directory tree of .md files.
Export {
/// Destination directory (created if needed).
dir: PathBuf,
},
/// One-time: rewrite legacy `[[Name]]` body links to the canonical
/// `[[node-id]]` form (tech-spec §8.4). Idempotent.
MigrateLinks,
/// Manage the hephd daemon as an OS service (launchd / systemd).
Daemon {
#[command(subcommand)]
action: service::DaemonAction,
},
/// Authenticate this device with a sync hub (OAuth 2.0 device-code flow).
Auth {
#[command(subcommand)]
action: AuthAction,
},
}
#[derive(Subcommand, Debug)]
enum NodeAction {
/// Update a node's title and/or body.
Update {
/// Node id.
id: String,
/// New title.
#[arg(long)]
title: Option<String>,
/// New markdown body (or `-` to read the body from stdin).
#[arg(long)]
body: Option<String>,
},
/// Tombstone (soft-delete) a node.
Rm {
/// Node id.
id: String,
},
}
#[derive(Subcommand, Debug)]
enum LinkAction {
/// Add a typed link between two nodes.
Add {
/// Source node id.
src: String,
/// Destination node id.
dst: String,
/// Link type: blocks|parent|tagged|in-project|context-of|…
link_type: String,
},
}
#[derive(Subcommand, Debug)]
enum ProjectAction {
/// Create a project node (optionally under a parent project).
Add {
/// Project name.
name: String,
/// Parent project name (creates a `parent` link).
#[arg(long)]
parent: Option<String>,
},
/// List all projects.
List,
}
#[derive(Subcommand, Debug)]
enum TagAction {
/// Tag a node.
Add {
/// Node id to tag.
node: String,
/// Tag name.
tag: String,
},
/// Remove a tag from a node.
Rm {
/// Node id.
node: String,
/// Tag name.
tag: String,
},
/// List a node's tags, or every tag in the store (with no node).
List {
/// Node id (omit to list all tags).
node: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum ConflictAction {
/// Resolve a conflict by choosing the local or remote value.
Resolve {
/// Conflict id.
id: String,
/// `local` or `remote`.
choice: String,
},
}
#[derive(Subcommand, Debug)]
enum AuthAction {
/// Log in via the device-code flow; caches the bearer token for hub sync.
Login {
/// Hub/server URL this token is for (keys the credential store entry).
#[arg(long)]
hub_url: String,
/// OIDC issuer, e.g. https://authentik.ops.eblu.me/application/o/heph/.
#[arg(long)]
issuer: String,
/// OIDC client id this device authenticates as.
#[arg(long)]
client_id: String,
/// Scopes to request (`offline_access` yields a refresh token).
#[arg(long, default_value = "openid offline_access")]
scope: String,
},
/// Forget the cached token for a hub.
Logout {
/// Hub/server URL whose cached token to remove.
#[arg(long)]
hub_url: String,
},
}
/// Run the device-code flow (or clear a token) — no daemon needed.
fn run_auth(action: AuthAction) -> Result<()> {
match action {
AuthAction::Login {
hub_url,
issuer,
client_id,
scope,
} => {
let flow = DeviceFlow::discover(&issuer, &client_id)?;
let auth = flow.start(&scope)?;
let uri = auth
.verification_uri_complete
.as_deref()
.unwrap_or(&auth.verification_uri);
println!(
"To authorize hephaestus, visit:\n {uri}\nand enter code: {}\n\nWaiting…",
auth.user_code
);
let token = flow.poll(&auth, std::thread::sleep)?;
KeyringTokenStore::new(hub_url.as_str()).save(&token)?;
println!("Logged in. Token cached for {hub_url}.");
}
AuthAction::Logout { hub_url } => {
KeyringTokenStore::new(hub_url.as_str()).clear()?;
println!("Logged out of {hub_url}.");
}
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
// `daemon` manages the OS service; it must not connect to a daemon.
if let Command::Daemon { action } = &cli.command {
return service::run(action);
}
// `auth` runs locally (device-code flow + keyring); it needs no daemon.
if let Command::Auth { action } = cli.command {
return run_auth(action);
}
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 }))?;
print_rows(result)?;
}
Command::Task {
title,
attention,
do_date,
late_on,
project,
recur,
rrule,
} => {
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call(
"task.create",
json!({
"title": title,
"attention": attention,
"do_date": opt_date_ms(do_date.as_deref())?,
"late_on": opt_date_ms(late_on.as_deref())?,
"project_id": project_id,
"recurrence": recurrence,
}),
)?;
let task: Task = serde_json::from_value(result)?;
println!("Created task {} \"{title}\"", task.node_id);
}
Command::List {
scope,
project,
attention,
no_blue,
json,
} => {
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
// daemon), a single `--attention` whitelist, and `--no-blue`.
let mut filter = json!({});
if let Some(s) = scope {
filter["scope"] = json!([s]);
}
if let Some(p) = project {
filter["project"] = json!(p);
}
if let Some(a) = attention {
filter["attention_in"] = json!([a]);
}
if no_blue {
filter["attention_not"] = json!(["blue"]);
}
let result = client.call("list", filter)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
print_rows(result)?;
}
}
Command::View { name } => match name {
Some(name) => {
let result = client.call("view", json!({ "name": name }))?;
print_rows(result)?;
}
None => {
println!("Available views (heph view <name>):");
for v in heph_core::BUILTIN_VIEWS {
println!(" {:<8} {}", v.name, v.title);
}
}
},
Command::Done { id } => {
set_state(&mut client, &id, "done")?;
}
Command::Drop { id } => {
set_state(&mut client, &id, "dropped")?;
}
Command::Skip { id } => {
client.call("task.skip", json!({ "id": id }))?;
println!("Skipped occurrence of {id}");
}
Command::Attention { id, attention } => {
client.call(
"task.set_attention",
json!({ "id": id, "attention": attention }),
)?;
println!("{id} attention → {attention}");
}
Command::Edit {
id,
do_date,
late_on,
recur,
rrule,
attention,
project,
} => {
let mut patch = serde_json::Map::new();
patch.insert("id".into(), json!(id));
if let Some(v) = sched_date(do_date.as_deref())? {
patch.insert("do_date".into(), v);
}
if let Some(v) = sched_date(late_on.as_deref())? {
patch.insert("late_on".into(), v);
}
let recur_spec = recur.or(rrule);
if let Some(v) = sched_recurrence(recur_spec.as_deref())? {
patch.insert("recurrence".into(), v);
}
if patch.len() > 1 {
client.call("task.set_schedule", Value::Object(patch))?;
}
if let Some(a) = attention {
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
}
if let Some(spec) = project.as_deref() {
// Re-file (or unfile with `none`) via the move-to-project path,
// which tombstones the old `in-project` link rather than piling
// a second one on top of it.
let project_id = match spec {
"none" | "clear" => None,
name => resolve_project(&mut client, Some(name))?,
};
client.call(
"task.set_project",
json!({ "id": id, "project_id": project_id }),
)?;
}
println!("Edited task {id}");
}
Command::Promote {
container_id,
item_ref,
attention,
project,
} => {
let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call(
"task.promote",
json!({
"container_id": container_id,
"item_ref": item_ref,
"attention": attention,
"project": project_id,
}),
)?;
let task: Task = serde_json::from_value(result)?;
println!("Promoted item {item_ref} → task {}", task.node_id);
}
Command::Show { id } => {
let node = client.call("node.get", json!({ "id": id }))?;
println!("{}", serde_json::to_string_pretty(&node)?);
if node.get("kind").and_then(Value::as_str) == Some("task") {
let task = client.call("task.get", json!({ "id": id }))?;
println!("task: {}", serde_json::to_string_pretty(&task)?);
}
}
Command::Log { id, text, n } => {
if text.is_empty() {
let entries = client.call("log.tail", json!({ "task_id": id, "n": n }))?;
let entries: Vec<String> = serde_json::from_value(entries)?;
if entries.is_empty() {
println!("(no log entries)");
}
for e in &entries {
println!("{e}");
}
} else {
let line = text.join(" ");
client.call("log.append", json!({ "task_id": id, "text": line }))?;
println!("Logged to {id}");
}
}
Command::Context { id, body, append } => {
let doc_id = canonical_context_id(&mut client, &id)?;
match (body, append) {
(Some(_), Some(_)) => bail!("pass only one of --body / --append"),
(Some(body), None) => {
let body = read_body_arg(Some(body))?;
client.call("node.update", json!({ "id": doc_id, "body": body }))?;
println!("Set context of {id}");
}
(None, Some(text)) => {
let current = context_body(&mut client, &doc_id)?;
let combined = if current.trim().is_empty() {
text
} else {
format!("{}\n\n{text}", current.trim_end())
};
client.call("node.update", json!({ "id": doc_id, "body": combined }))?;
println!("Appended to context of {id}");
}
(None, None) => {
let current = context_body(&mut client, &doc_id)?;
if current.trim().is_empty() {
println!("(empty context)");
} else {
println!("{}", current.trim_end());
}
}
}
}
Command::Health => {
let h = client.call("health", json!({}))?;
println!(
"orange {} active {} on-deck {} conflicts {} sync {}",
num(&h, "orange_count"),
num(&h, "active_count"),
num(&h, "on_deck_count"),
num(&h, "conflict_count"),
h.get("sync_status").and_then(Value::as_str).unwrap_or("?"),
);
}
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::Node { action } => match action {
NodeAction::Update { id, title, body } => {
let body = read_body_arg(body)?;
let result = client.call(
"node.update",
json!({ "id": id, "title": title, "body": body }),
)?;
let node: Node = serde_json::from_value(result)?;
println!("Updated {} \"{}\"", node.id, node.title);
}
NodeAction::Rm { id } => {
client.call("node.tombstone", json!({ "id": id }))?;
println!("Tombstoned {id}");
}
},
Command::Get { id } => {
let result = client.call("node.get", json!({ "id": id }))?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
Command::Resolve { title } => {
let result = client.call("node.resolve", json!({ "title": title }))?;
if result.is_null() {
println!("(no match)");
} else {
let node: Node = serde_json::from_value(result)?;
println!("{} [{}] {}", node.id, node.kind.as_str(), node.title);
}
}
Command::Search { query } => {
let result = client.call("search", json!({ "query": query }))?;
let nodes: Vec<Node> = serde_json::from_value(result)?;
if nodes.is_empty() {
println!("No matches.");
}
for n in &nodes {
println!("{} [{}] {}", n.id, n.kind.as_str(), n.title);
}
}
Command::Journal { date } => {
let result = client.call("journal.open_or_create", json!({ "date": date }))?;
let node: Node = serde_json::from_value(result)?;
println!("Journal {} ({})", node.title, node.id);
}
Command::Links { id } => {
print_links(client.call("links.outgoing", json!({ "id": id }))?);
}
Command::Backlinks { id } => {
print_links(client.call("links.backlinks", json!({ "id": id }))?);
}
Command::Link { action } => match action {
LinkAction::Add {
src,
dst,
link_type,
} => {
client.call(
"links.add",
json!({ "src": src, "dst": dst, "link_type": link_type }),
)?;
println!("Linked {src} -[{link_type}]-> {dst}");
}
},
Command::Project { action } => match action {
ProjectAction::Add { name, parent } => {
let result =
client.call("node.create", json!({ "kind": "project", "title": name }))?;
let node: Node = serde_json::from_value(result)?;
if let Some(parent) = parent {
let pid = resolve_project(&mut client, Some(&parent))?
.context("parent project not found")?;
client.call(
"links.add",
json!({ "src": node.id, "dst": pid, "link_type": "parent" }),
)?;
}
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::Tag { action } => match action {
TagAction::Add { node, tag } => {
let result = client.call("tag.add", json!({ "node_id": node, "tag": tag }))?;
let t: Node = serde_json::from_value(result)?;
println!("{node} tagged #{}", t.title);
}
TagAction::Rm { node, tag } => {
client.call("tag.remove", json!({ "node_id": node, "tag": tag }))?;
println!("{node} untagged #{tag}");
}
TagAction::List { node } => {
let tags: Vec<String> = match node {
Some(node) => {
let result = client.call("tag.list", json!({ "node_id": node }))?;
serde_json::from_value(result)?
}
None => {
let result = client.call("node.list", json!({ "kind": "tag" }))?;
let nodes: Vec<Node> = serde_json::from_value(result)?;
nodes.into_iter().map(|n| n.title).collect()
}
};
if tags.is_empty() {
println!("(no tags)");
}
for t in &tags {
println!("#{t}");
}
}
},
Command::Sync { status } => {
let method = if status { "sync.status" } else { "sync.now" };
let result = client.call(method, json!({}))?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
Command::Conflicts { action } => match action {
None => {
let result = client.call("conflicts.list", json!({}))?;
let arr = result.as_array().cloned().unwrap_or_default();
if arr.is_empty() {
println!("No conflicts.");
}
for c in &arr {
println!("{}", serde_json::to_string(c)?);
}
}
Some(ConflictAction::Resolve { id, choice }) => {
client.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?;
println!("Resolved {id}{choice}");
}
},
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());
}
Command::MigrateLinks => {
let result = client.call("migrate.wikilinks", json!({}))?;
let n = result.as_u64().unwrap_or(0);
println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s).");
}
Command::Auth { .. } => unreachable!("auth is handled before connecting"),
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
}
Ok(())
}
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
spec.map(datespec::parse_date_ms).transpose()
}
/// A `task.set_schedule` date field value: `Some(null)` to clear (`"none"`),
/// `Some(<ms>)` to set, `None` to omit (leave unchanged).
fn sched_date(spec: Option<&str>) -> Result<Option<Value>> {
match spec {
None => Ok(None),
Some("none") => Ok(Some(Value::Null)),
Some(s) => Ok(Some(json!(datespec::parse_date_ms(s)?))),
}
}
/// A `task.set_schedule` recurrence value, same tri-state as [`sched_date`].
fn sched_recurrence(spec: Option<&str>) -> Result<Option<Value>> {
match spec {
None => Ok(None),
Some("none") => Ok(Some(Value::Null)),
Some(s) => Ok(Some(json!(datespec::parse_recurrence(s)?))),
}
}
/// Recurrence for `task.create`: a raw `--rrule` wins, else parse `--recur`.
fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result<Option<String>> {
if let Some(raw) = rrule {
return Ok(Some(raw.to_string()));
}
recur.map(datespec::parse_recurrence).transpose()
}
/// Resolve a project **name** to its node id, erroring if no project matches.
/// Matching is case-insensitive and prefix-fuzzy when unambiguous (server-side,
/// shared with `heph list --project`); an exact title always wins outright.
fn resolve_project(client: &mut Client, name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name else { return Ok(None) };
let result = client.call("project.resolve", json!({ "name": name }))?;
if result.is_null() {
bail!("no project named {name:?} (create it with: heph project add {name:?})");
}
let node: Node = serde_json::from_value(result)?;
Ok(Some(node.id))
}
/// Resolve a task's canonical-context doc id by walking its outgoing links.
/// Errors if the node has no such doc (e.g. it isn't a task).
fn canonical_context_id(client: &mut Client, task_id: &str) -> Result<String> {
let links = client.call("links.outgoing", json!({ "id": task_id }))?;
let dst = links
.as_array()
.into_iter()
.flatten()
.find(|l| l.get("link_type").and_then(Value::as_str) == Some("canonical-context"))
.and_then(|l| l.get("dst_id").and_then(Value::as_str));
match dst {
Some(id) => Ok(id.to_string()),
None => bail!("{task_id} has no canonical-context doc (is it a task?)"),
}
}
/// Fetch a context doc's current body (empty string when unset).
fn context_body(client: &mut Client, doc_id: &str) -> Result<String> {
let node = client.call("node.get", json!({ "id": doc_id }))?;
Ok(node
.get("body")
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
/// `--body -` reads the body from stdin; otherwise pass it through.
fn read_body_arg(body: Option<String>) -> Result<Option<String>> {
match body.as_deref() {
Some("-") => {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
Ok(Some(s))
}
_ => Ok(body),
}
}
fn set_state(client: &mut Client, id: &str, state: &str) -> Result<()> {
client.call("task.set_state", json!({ "id": id, "state": state }))?;
println!("{id}{state}");
Ok(())
}
fn num(v: &Value, key: &str) -> u64 {
v.get(key).and_then(Value::as_u64).unwrap_or(0)
}
/// Print a list of `RankedTask` rows (shared by `next` and `list`).
fn print_rows(result: Value) -> Result<()> {
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));
}
Ok(())
}
fn print_links(result: Value) {
let arr = result.as_array().cloned().unwrap_or_default();
if arr.is_empty() {
println!("(no links)");
}
for l in &arr {
let ty = l.get("link_type").and_then(Value::as_str).unwrap_or("?");
let src = l.get("src_id").and_then(Value::as_str).unwrap_or("?");
let dst = l.get("dst_id").and_then(Value::as_str).unwrap_or("?");
println!("{src} -[{ty}]-> {dst}");
}
}
/// One concise Tactical row: attention tag, title, and human 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:{}", datespec::fmt_date(d)));
}
if let Some(l) = t.late_on {
extra.push(format!("late:{}", datespec::fmt_date(l)));
}
let suffix = if extra.is_empty() {
String::new()
} else {
format!(" ({})", extra.join(", "))
};
format!("{tag} {}{suffix} {}", t.title, t.node_id)
}