generated from eblume/project-template
feat(cli): complete task surface — human dates, recurrence, full API
Some checks failed
Build / validate (pull_request) Failing after 1m52s
Some checks failed
Build / validate (pull_request) Failing after 1m52s
Make heph a real task driver and the complete daemon-API surface (the
three-surface model's capture/scripting role). Structured fields are flags.
- datespec: human date parsing (today/tomorrow/+3d/fri/ISO, injectable today
for deterministic tests) + compact display; recurrence presets + the common
Todoist-style natural-language forms ("every 3 days", "every fri", "every
April 15") + raw RRULE passthrough. Table-driven unit tests.
- main: new commands covering every RPC — list, done/drop/skip, attention,
edit (reschedule via task.set_schedule), promote, show, log (append/tail),
health, node update/rm, resolve, links/backlinks, link add,
project add [--parent], sync [--status], conflicts [resolve]. task/next/list
show human dates; projects referenced by name (resolved, errors if absent).
- tests/cli.rs: real-socket process tests for the new verbs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70d5af5bdc
commit
07e4d786b3
6 changed files with 1025 additions and 25 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1135,6 +1135,7 @@ name = "heph"
|
|||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"heph-core",
|
||||
"hephd",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ hephd = { path = "../hephd" }
|
|||
clap.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
372
crates/heph/src/datespec.rs
Normal file
372
crates/heph/src/datespec.rs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
//! Human-friendly date and recurrence parsing for the CLI (tech-spec §1, §8).
|
||||
//!
|
||||
//! `heph-core` is clock-pure (no ambient wall-clock reads); the CLI is a client,
|
||||
//! so it may read the local clock. Date parsing is split so the logic is
|
||||
//! deterministically testable: the pure functions take `today` / a year, and the
|
||||
//! thin wrappers supply `Local::now()`.
|
||||
//!
|
||||
//! Recurrence input mirrors how the owner thinks in Todoist (see [[design]]
|
||||
//! §6.2.1): presets, the common natural-language forms, or a raw RRULE.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{Datelike, Days, Local, Months, NaiveDate, TimeZone, Weekday};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a human date spec **relative to `today`** into a `NaiveDate`. Accepts:
|
||||
/// `today`, `tomorrow`/`tom`, `yesterday`; `+Nd`/`+Nw`/`+Nm` (and bare `+N` =
|
||||
/// days); weekday names (`mon`..`sun`, the soonest such day on/after today);
|
||||
/// ISO `YYYY-MM-DD`.
|
||||
pub fn parse_date(input: &str, today: NaiveDate) -> Result<NaiveDate> {
|
||||
let s = input.trim().to_lowercase();
|
||||
if s.is_empty() {
|
||||
bail!("empty date");
|
||||
}
|
||||
match s.as_str() {
|
||||
"today" | "now" => return Ok(today),
|
||||
"tomorrow" | "tom" => return Ok(today + Days::new(1)),
|
||||
"yesterday" => return Ok(today - Days::new(1)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(wd) = parse_weekday(&s) {
|
||||
return Ok(soonest_weekday(today, wd));
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix('+') {
|
||||
return parse_offset(rest, today);
|
||||
}
|
||||
NaiveDate::parse_from_str(&s, "%Y-%m-%d").with_context(|| {
|
||||
format!("unrecognized date: {input:?} (try today, tomorrow, +3d, fri, or YYYY-MM-DD)")
|
||||
})
|
||||
}
|
||||
|
||||
/// `parse_date` against the local `today`.
|
||||
pub fn parse_date_today(input: &str) -> Result<NaiveDate> {
|
||||
parse_date(input, Local::now().date_naive())
|
||||
}
|
||||
|
||||
/// Parse a human date spec to epoch milliseconds at **local midnight** — the
|
||||
/// form `do_date`/`late_on` are stored in (a date-grained candidacy gate, §7).
|
||||
pub fn parse_date_ms(input: &str) -> Result<i64> {
|
||||
Ok(to_epoch_ms(parse_date_today(input)?))
|
||||
}
|
||||
|
||||
/// Local-midnight epoch ms for a date. Falls back gracefully across a DST gap.
|
||||
pub fn to_epoch_ms(date: NaiveDate) -> i64 {
|
||||
let midnight = date.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
|
||||
match Local.from_local_datetime(&midnight).earliest() {
|
||||
Some(dt) => dt.timestamp_millis(),
|
||||
None => midnight.and_utc().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact display of an epoch-ms date: `MM-DD` within the current year,
|
||||
/// `YYYY-MM-DD` otherwise.
|
||||
pub fn fmt_date(ms: i64) -> String {
|
||||
let date = match Local.timestamp_millis_opt(ms).earliest() {
|
||||
Some(dt) => dt.date_naive(),
|
||||
None => return ms.to_string(),
|
||||
};
|
||||
fmt_naive(date, Local::now().date_naive().year())
|
||||
}
|
||||
|
||||
fn fmt_naive(date: NaiveDate, this_year: i32) -> String {
|
||||
if date.year() == this_year {
|
||||
format!("{:02}-{:02}", date.month(), date.day())
|
||||
} else {
|
||||
date.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
|
||||
let rest = rest.trim();
|
||||
let (num, unit) = rest.split_at(
|
||||
rest.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(rest.len()),
|
||||
);
|
||||
let n: u64 = num
|
||||
.parse()
|
||||
.with_context(|| format!("not a relative date offset: +{rest}"))?;
|
||||
match unit.trim() {
|
||||
"" | "d" | "day" | "days" => Ok(today + Days::new(n)),
|
||||
"w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)),
|
||||
"m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)),
|
||||
other => bail!("unknown offset unit {other:?} (use d, w, or m)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches
|
||||
/// only recognized weekday tokens — not arbitrary prefixes (so "months" is not
|
||||
/// "mon").
|
||||
fn parse_weekday(s: &str) -> Option<Weekday> {
|
||||
match s {
|
||||
"mon" | "monday" => Some(Weekday::Mon),
|
||||
"tue" | "tues" | "tuesday" => Some(Weekday::Tue),
|
||||
"wed" | "weds" | "wednesday" => Some(Weekday::Wed),
|
||||
"thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu),
|
||||
"fri" | "friday" => Some(Weekday::Fri),
|
||||
"sat" | "saturday" => Some(Weekday::Sat),
|
||||
"sun" | "sunday" => Some(Weekday::Sun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The soonest date on/after `today` whose weekday is `wd`.
|
||||
fn soonest_weekday(today: NaiveDate, wd: Weekday) -> NaiveDate {
|
||||
let mut d = today;
|
||||
for _ in 0..7 {
|
||||
if d.weekday() == wd {
|
||||
return d;
|
||||
}
|
||||
d = d + Days::new(1);
|
||||
}
|
||||
today // unreachable: a weekday occurs within 7 days
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recurrence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE
|
||||
/// (anything containing `FREQ=`), presets (`daily|weekly|monthly|yearly|
|
||||
/// weekdays`), and the common natural-language forms the owner uses in Todoist
|
||||
/// (§6.2.1): `every N (day|week|month|year)s`, `every <weekday>`, `every other
|
||||
/// <weekday|unit>`, `every workday`, `every <Month> <day>`. A trailing
|
||||
/// `at <time>` is ignored (heph's do-date is date-grained).
|
||||
pub fn parse_recurrence(spec: &str) -> Result<String> {
|
||||
let raw = spec.trim();
|
||||
if raw.to_uppercase().contains("FREQ=") {
|
||||
return Ok(raw.to_string());
|
||||
}
|
||||
|
||||
// Normalize: lowercase, drop a trailing "at <time>" clause.
|
||||
let mut s = raw.to_lowercase();
|
||||
if let Some(at) = s.find(" at ") {
|
||||
s.truncate(at);
|
||||
}
|
||||
let s = s.trim();
|
||||
|
||||
// Presets.
|
||||
match s {
|
||||
"daily" | "day" => return Ok("FREQ=DAILY".into()),
|
||||
"weekly" | "week" => return Ok("FREQ=WEEKLY".into()),
|
||||
"monthly" | "month" => return Ok("FREQ=MONTHLY".into()),
|
||||
"yearly" | "annually" | "year" => return Ok("FREQ=YEARLY".into()),
|
||||
"weekdays" | "workdays" | "workday" | "weekday" => {
|
||||
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let body = s.strip_prefix("every ").unwrap_or(s).trim();
|
||||
|
||||
// "every other <...>" → INTERVAL=2.
|
||||
if let Some(rest) = body.strip_prefix("other ") {
|
||||
return interval_form(2, rest.trim());
|
||||
}
|
||||
// "every workday/weekday".
|
||||
if body == "workday" || body == "weekday" || body == "workdays" || body == "weekdays" {
|
||||
return Ok("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into());
|
||||
}
|
||||
// "every <weekday>".
|
||||
if let Some(wd) = parse_weekday(body) {
|
||||
return Ok(format!("FREQ=WEEKLY;BYDAY={}", byday(wd)));
|
||||
}
|
||||
// "every <Month> <day>".
|
||||
if let Some((m, d)) = parse_month_day(body) {
|
||||
return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}"));
|
||||
}
|
||||
// "every <unit>" / "every N <unit>s".
|
||||
let mut it = body.split_whitespace();
|
||||
let first = it.next().unwrap_or("");
|
||||
if let Ok(n) = first.parse::<u32>() {
|
||||
return interval_form(n, it.next().unwrap_or(""));
|
||||
}
|
||||
interval_form(1, first)
|
||||
}
|
||||
|
||||
/// `FREQ=...[;INTERVAL=n]` for a unit word, or a weekday with an interval.
|
||||
fn interval_form(n: u32, unit: &str) -> Result<String> {
|
||||
if let Some(wd) = parse_weekday(unit) {
|
||||
let mut r = format!("FREQ=WEEKLY;BYDAY={}", byday(wd));
|
||||
if n != 1 {
|
||||
r = format!("FREQ=WEEKLY;INTERVAL={n};BYDAY={}", byday(wd));
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
let freq = match unit {
|
||||
"day" | "days" => "DAILY",
|
||||
"week" | "weeks" => "WEEKLY",
|
||||
"month" | "months" => "MONTHLY",
|
||||
"year" | "years" => "YEARLY",
|
||||
other => bail!(
|
||||
"unrecognized recurrence {other:?} (try daily/weekly/monthly/yearly, \
|
||||
'every 3 days', 'every fri', or a raw RRULE)"
|
||||
),
|
||||
};
|
||||
Ok(if n == 1 {
|
||||
format!("FREQ={freq}")
|
||||
} else {
|
||||
format!("FREQ={freq};INTERVAL={n}")
|
||||
})
|
||||
}
|
||||
|
||||
fn byday(wd: Weekday) -> &'static str {
|
||||
match wd {
|
||||
Weekday::Mon => "MO",
|
||||
Weekday::Tue => "TU",
|
||||
Weekday::Wed => "WE",
|
||||
Weekday::Thu => "TH",
|
||||
Weekday::Fri => "FR",
|
||||
Weekday::Sat => "SA",
|
||||
Weekday::Sun => "SU",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day).
|
||||
fn parse_month_day(s: &str) -> Option<(u32, u32)> {
|
||||
let toks: Vec<&str> = s.split_whitespace().collect();
|
||||
if toks.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let month = |t: &str| -> Option<u32> {
|
||||
match &t[..t.len().min(3)] {
|
||||
"jan" => Some(1),
|
||||
"feb" => Some(2),
|
||||
"mar" => Some(3),
|
||||
"apr" => Some(4),
|
||||
"may" => Some(5),
|
||||
"jun" => Some(6),
|
||||
"jul" => Some(7),
|
||||
"aug" => Some(8),
|
||||
"sep" => Some(9),
|
||||
"oct" => Some(10),
|
||||
"nov" => Some(11),
|
||||
"dec" => Some(12),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let day = |t: &str| {
|
||||
t.trim_end_matches(|c: char| !c.is_ascii_digit())
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
};
|
||||
if let (Some(m), Some(d)) = (month(toks[0]), day(toks[1])) {
|
||||
return Some((m, d));
|
||||
}
|
||||
if let (Some(d), Some(m)) = (day(toks[0]), month(toks[1])) {
|
||||
return Some((m, d));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 2026-06-02 is a Tuesday.
|
||||
fn today() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 6, 2).unwrap()
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_keywords_and_offsets() {
|
||||
let t = today();
|
||||
assert_eq!(parse_date("today", t).unwrap(), d(2026, 6, 2));
|
||||
assert_eq!(parse_date("tomorrow", t).unwrap(), d(2026, 6, 3));
|
||||
assert_eq!(parse_date("yesterday", t).unwrap(), d(2026, 6, 1));
|
||||
assert_eq!(parse_date("+3d", t).unwrap(), d(2026, 6, 5));
|
||||
assert_eq!(parse_date("+2w", t).unwrap(), d(2026, 6, 16));
|
||||
assert_eq!(parse_date("+1m", t).unwrap(), d(2026, 7, 2));
|
||||
assert_eq!(parse_date("+5", t).unwrap(), d(2026, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_weekdays_are_soonest_on_or_after_today() {
|
||||
let t = today(); // Tuesday
|
||||
assert_eq!(
|
||||
parse_date("tue", t).unwrap(),
|
||||
d(2026, 6, 2),
|
||||
"today if it matches"
|
||||
);
|
||||
assert_eq!(parse_date("fri", t).unwrap(), d(2026, 6, 5));
|
||||
assert_eq!(
|
||||
parse_date("mon", t).unwrap(),
|
||||
d(2026, 6, 8),
|
||||
"wraps to next week"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_iso_and_errors() {
|
||||
assert_eq!(parse_date("2026-12-25", today()).unwrap(), d(2026, 12, 25));
|
||||
assert!(parse_date("someday", today()).is_err());
|
||||
assert!(parse_date("", today()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_naive_elides_the_current_year() {
|
||||
assert_eq!(fmt_naive(d(2026, 6, 5), 2026), "06-05");
|
||||
assert_eq!(fmt_naive(d(2027, 1, 9), 2026), "2027-01-09");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_presets_and_raw() {
|
||||
assert_eq!(parse_recurrence("daily").unwrap(), "FREQ=DAILY");
|
||||
assert_eq!(parse_recurrence("weekly").unwrap(), "FREQ=WEEKLY");
|
||||
assert_eq!(parse_recurrence("monthly").unwrap(), "FREQ=MONTHLY");
|
||||
assert_eq!(parse_recurrence("yearly").unwrap(), "FREQ=YEARLY");
|
||||
assert_eq!(
|
||||
parse_recurrence("weekdays").unwrap(),
|
||||
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
|
||||
);
|
||||
// Raw RRULE passes through untouched.
|
||||
assert_eq!(
|
||||
parse_recurrence("FREQ=DAILY;INTERVAL=2").unwrap(),
|
||||
"FREQ=DAILY;INTERVAL=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_natural_language() {
|
||||
assert_eq!(parse_recurrence("every day").unwrap(), "FREQ=DAILY");
|
||||
assert_eq!(
|
||||
parse_recurrence("every 3 days").unwrap(),
|
||||
"FREQ=DAILY;INTERVAL=3"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every 2 weeks").unwrap(),
|
||||
"FREQ=WEEKLY;INTERVAL=2"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every 6 months").unwrap(),
|
||||
"FREQ=MONTHLY;INTERVAL=6"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every fri").unwrap(),
|
||||
"FREQ=WEEKLY;BYDAY=FR"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every other wed").unwrap(),
|
||||
"FREQ=WEEKLY;INTERVAL=2;BYDAY=WE"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every other day").unwrap(),
|
||||
"FREQ=DAILY;INTERVAL=2"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every workday at 08:00").unwrap(),
|
||||
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_recurrence("every April 15").unwrap(),
|
||||
"FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15"
|
||||
);
|
||||
assert!(parse_recurrence("every blue moon").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
//! `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`.
|
||||
//! `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::{Context, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use heph_core::{Node, RankedTask, Task};
|
||||
use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
||||
|
||||
mod datespec;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "heph", version, about)]
|
||||
struct Cli {
|
||||
|
|
@ -38,21 +44,113 @@ enum Command {
|
|||
/// The task title.
|
||||
title: String,
|
||||
/// Attention-state: white|orange|red|blue.
|
||||
#[arg(long)]
|
||||
#[arg(short = 'a', long)]
|
||||
attention: Option<String>,
|
||||
/// Earliest-actionable date, epoch ms.
|
||||
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
|
||||
#[arg(long)]
|
||||
do_date: Option<i64>,
|
||||
/// Lateness-problem marker, epoch ms.
|
||||
do_date: Option<String>,
|
||||
/// Late-on (the sole urgency marker): same date forms as --do-date.
|
||||
#[arg(long)]
|
||||
late_on: Option<i64>,
|
||||
/// Project node id to file it under.
|
||||
late_on: Option<String>,
|
||||
/// Project name to file it under (must already exist).
|
||||
#[arg(long)]
|
||||
project: Option<String>,
|
||||
/// RFC-5545 RRULE for a recurring task.
|
||||
/// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL
|
||||
/// ("every 3 days", "every fri").
|
||||
#[arg(long)]
|
||||
recurrence: Option<String>,
|
||||
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,
|
||||
},
|
||||
/// 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>,
|
||||
/// File under a project (by name; adds an in-project link).
|
||||
#[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.
|
||||
|
|
@ -61,11 +159,21 @@ enum Command {
|
|||
#[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.
|
||||
|
|
@ -76,6 +184,37 @@ enum Command {
|
|||
/// 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,
|
||||
},
|
||||
/// 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).
|
||||
|
|
@ -88,6 +227,62 @@ enum Command {
|
|||
},
|
||||
}
|
||||
|
||||
#[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>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
|
@ -158,13 +353,7 @@ fn main() -> Result<()> {
|
|||
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));
|
||||
}
|
||||
print_rows(result)?;
|
||||
}
|
||||
Command::Task {
|
||||
title,
|
||||
|
|
@ -172,22 +361,142 @@ fn main() -> Result<()> {
|
|||
do_date,
|
||||
late_on,
|
||||
project,
|
||||
recurrence,
|
||||
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": do_date,
|
||||
"late_on": late_on,
|
||||
"project_id": project,
|
||||
"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,
|
||||
} => {
|
||||
let result = client.call(
|
||||
"list",
|
||||
json!({ "scope": scope, "attention": attention, "include_blue": !no_blue }),
|
||||
)?;
|
||||
print_rows(result)?;
|
||||
}
|
||||
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(pid) = resolve_project(&mut client, project.as_deref())? {
|
||||
client.call(
|
||||
"links.add",
|
||||
json!({ "src": id, "dst": pid, "link_type": "in-project" }),
|
||||
)?;
|
||||
}
|
||||
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",
|
||||
|
|
@ -196,10 +505,34 @@ fn main() -> Result<()> {
|
|||
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)?;
|
||||
|
|
@ -215,6 +548,62 @@ fn main() -> Result<()> {
|
|||
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);
|
||||
}
|
||||
},
|
||||
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()
|
||||
|
|
@ -229,7 +618,104 @@ fn main() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// One concise Tactical row: attention tag, title, and do/late context.
|
||||
/// 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
|
||||
|
|
@ -237,15 +723,15 @@ fn format_row(t: &RankedTask) -> String {
|
|||
.unwrap_or_else(|| "[ ]".to_string());
|
||||
let mut extra = Vec::new();
|
||||
if let Some(d) = t.do_date {
|
||||
extra.push(format!("do:{d}"));
|
||||
extra.push(format!("do:{}", datespec::fmt_date(d)));
|
||||
}
|
||||
if let Some(l) = t.late_on {
|
||||
extra.push(format!("late:{l}"));
|
||||
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)
|
||||
format!("{tag} {}{suffix} {}", t.title, t.node_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,145 @@ fn next_on_empty_store_is_friendly() {
|
|||
assert!(out.contains("Nothing actionable"), "{out}");
|
||||
}
|
||||
|
||||
/// The first whitespace token of a "Created task <id> …" line.
|
||||
fn created_id(out: &str) -> String {
|
||||
out.split_whitespace()
|
||||
.nth(2)
|
||||
.expect("id in output")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_with_iso_do_date_shows_formatted_date_in_next() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
// A do-date in the past (relative to the daemon's fixed 2024-01-01 clock)
|
||||
// so it is actionable and appears in `next`.
|
||||
let (out, ok) = heph(
|
||||
&socket,
|
||||
&[
|
||||
"task",
|
||||
"Renew passport",
|
||||
"-a",
|
||||
"orange",
|
||||
"--do-date",
|
||||
"2023-12-25",
|
||||
],
|
||||
);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["next"]);
|
||||
assert!(ok);
|
||||
assert!(out.contains("Renew passport"), "{out}");
|
||||
// Within 2023 vs the 2024 clock → full YYYY-MM-DD form.
|
||||
assert!(
|
||||
out.contains("do:2023-12-25"),
|
||||
"expected formatted do-date: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_reschedules_and_clears_fields() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, _) = heph(
|
||||
&socket,
|
||||
&[
|
||||
"task",
|
||||
"Taxes",
|
||||
"--do-date",
|
||||
"2023-01-01",
|
||||
"--recur",
|
||||
"yearly",
|
||||
],
|
||||
);
|
||||
let id = created_id(&out);
|
||||
|
||||
// Reschedule do-date and clear recurrence.
|
||||
let (out, ok) = heph(
|
||||
&socket,
|
||||
&["edit", &id, "--do-date", "2023-06-01", "--recur", "none"],
|
||||
);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["show", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(
|
||||
out.contains("\"recurrence\": null"),
|
||||
"recurrence cleared: {out}"
|
||||
);
|
||||
assert!(out.contains("\"do_date\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_and_done_and_health() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]);
|
||||
let id = created_id(&out);
|
||||
|
||||
let (out, ok) = heph(&socket, &["list"]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("Mow lawn"), "{out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["health"]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("active"), "{out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["done", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("done"), "{out}");
|
||||
|
||||
// Done tasks drop out of `next`.
|
||||
let (out, ok) = heph(&socket, &["next"]);
|
||||
assert!(ok);
|
||||
assert!(
|
||||
!out.contains("Mow lawn"),
|
||||
"completed task should be gone: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recur_preset_makes_a_recurring_task() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]);
|
||||
let id = created_id(&out);
|
||||
let (out, ok) = heph(&socket, &["show", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_add_then_file_a_task_under_it() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
// A task can be filed under the project by name.
|
||||
let (out, ok) = heph(
|
||||
&socket,
|
||||
&["task", "Replace filter", "--project", "Maintenance"],
|
||||
);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
// An unknown project name is a clear error, not a silent miss.
|
||||
let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]);
|
||||
assert!(!ok, "expected failure for unknown project: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_append_then_tail() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, _) = heph(&socket, &["task", "Investigate bug"]);
|
||||
let id = created_id(&out);
|
||||
|
||||
let (_, ok) = heph(
|
||||
&socket,
|
||||
&["log", &id, "looked", "at", "the", "stack", "trace"],
|
||||
);
|
||||
assert!(ok);
|
||||
let (out, ok) = heph(&socket, &["log", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("looked at the stack trace"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_markdown_files() {
|
||||
let (socket, dir) = spawn_daemon();
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- `heph.nvim` managed daemon — plug-and-play by default: `require("heph").setup({})` spawns and supervises a local `hephd` against the default paths when none is running, kills only the daemon it spawned on exit, and self-heals (respawns + reconnects if the daemon dies mid-session). A daemon you started yourself (a `server`/`client` architecture, or a service) is always respected — the plugin only spawns when nothing is serving the socket; with `autostart = false` it connects only and warns if unreachable. `$HEPH_SOCKET` / `$HEPH_DB` isolate a development Neovim onto a separate daemon + DB.
|
||||
- `heph.nvim` follow-or-create: pressing `<CR>` on a `[[wiki-link]]` whose target doesn't exist yet now **creates** a doc with that title and opens it (the zettelkasten gesture), materializing the source's backlink — so you can link a journal entry to a brand-new note in one keystroke. Plus `:Heph doc <title>` to create a standalone wiki entry, and `:Heph home` — a single designated landing/index page (open-or-create by title, configurable via `opts.home`) to grow a map of content around. `:Heph journals` opens a recent-days picker (preview existing days, `@create` for new ones; count via `opts.journal_days`, default 7) — the dailies workflow. Pickers (Telescope) now support a preview pane. The `:Heph next`/`list` views are interactive: `<CR>` opens a task's context, `a` adds a task (prompt title + attention), `d` marks the task under the cursor done, `r` refreshes — with a dimmed key hint shown above the list.
|
||||
- Dev/installed isolation tooling: a `mise run dev` task runs the working-tree `hephd` on isolated `.dev/` paths, and a how-to ([[install-heph]]) covers installing `heph`/`hephd` from the forge (build-from-source), the lazy.nvim plugin setup, and pointing a dev Neovim at the dev daemon via `$HEPH_SOCKET`/`$HEPH_DB` so it never touches the installed store.
|
||||
- CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue