hephaestus/crates/heph/src/main.rs
Erich Blume 4cdf0de64c
Some checks failed
Build / validate (pull_request) Failing after 4m35s
feat(core): tags — canonical tag nodes + OR-set tagging (§4, §8.3)
A tag is a `tag`-kind node with a deterministic id in (owner, name)
(`tag:<owner>:<name>`, like the journal), so a name is one canonical tag
shared across nodes and replicas converge with no duplicates. Tagging is
an OR-set `tagged` link (mirroring in-project):

- heph-core: `nodes::open_or_create_tag` (bodyless, deterministic id),
  `tags::{add,remove,of}`, and `Store::{add_tag,remove_tag,tags_of}`.
  Enumerate all tags via the existing `list_nodes(Tag)`.
- hephd: `tag.add`/`tag.remove`/`tag.list` RPCs + RemoteStore forwarding.
- heph: `heph tag add|rm|list` (a node's tags, or every tag).

Names are trimmed; canonical case/spelling normalization is deferred to
the zk import. Unblocks the `tags:` line of the frontmatter surface.
Tests: core add/dedupe/remove/canonical-id/trim/missing-node + a socket
add/list/enumerate/remove test.

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

851 lines
29 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, 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>,
/// 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,
},
/// 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,
},
/// 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,
},
/// 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,
attention,
no_blue,
} => {
// `list` takes a ListFilter (tech-spec §8.2). Map the legacy flags:
// a single `--scope` id, a single `--attention` whitelist, and
// `--no-blue` as an attention exclusion.
let mut filter = json!({});
if let Some(s) = scope {
filter["scope"] = json!([s]);
}
if let Some(a) = attention {
filter["attention_in"] = json!([a]);
}
if no_blue {
filter["attention_not"] = json!(["blue"]);
}
let result = client.call("list", filter)?;
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::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::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 it isn't a project.
fn resolve_project(client: &mut Client, name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name else { return Ok(None) };
let result = client.call("node.resolve", json!({ "title": 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)?;
if node.kind.as_str() != "project" {
bail!(
"{name:?} resolves to a {} node, not a project",
node.kind.as_str()
);
}
Ok(Some(node.id))
}
/// `--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)
}