generated from eblume/project-template
Merge pull request 'feat: CLI quick wins — heph-tui --version, fuzzy --project, heph context, version RPC' (#6) from feature/cli-quick-wins into main
All checks were successful
Build / validate (push) Successful in 5m51s
All checks were successful
Build / validate (push) Successful in 5m51s
Reviewed-on: #6
This commit is contained in:
commit
e4f1fd5ff8
14 changed files with 256 additions and 19 deletions
|
|
@ -210,21 +210,66 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result
|
|||
|
||||
/// Resolve a project **name** to its node id, restricted to `project`-kind
|
||||
/// nodes (so a like-named task/doc never wins). `None` if no such project.
|
||||
/// Used by filter-view scope/exclude resolution (tech-spec §8.2).
|
||||
/// Used by filter-view scope/exclude resolution (tech-spec §8.2) and the
|
||||
/// `--project` CLI argument across `task`/`edit`/`promote`/`list`.
|
||||
///
|
||||
/// Matching is **case-insensitive and prefix-fuzzy, but only when
|
||||
/// unambiguous** — a forgiving `--project hephaestus` / `--project heph`
|
||||
/// without sacrificing determinism:
|
||||
///
|
||||
/// 1. **Exact** (case-sensitive) — the historical behavior; an exact title
|
||||
/// always wins outright, so adding the fuzzy tiers can never change a name
|
||||
/// that already resolved.
|
||||
/// 2. **Case-insensitive exact** — accepted only if exactly one project
|
||||
/// matches (`Work` vs `work` is ambiguous → no match).
|
||||
/// 3. **Case-insensitive prefix** — accepted only if exactly one project's
|
||||
/// title starts with the name.
|
||||
///
|
||||
/// Ambiguous fuzzy matches resolve to `None` (treated as "no such project" by
|
||||
/// callers) rather than silently picking one.
|
||||
pub(super) fn resolve_project_id(
|
||||
conn: &Connection,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM nodes
|
||||
WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0
|
||||
ORDER BY created_at, id LIMIT 1",
|
||||
(name, owner),
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
// Live projects for this owner, in the deterministic (created_at, id) order
|
||||
// the exact path has always used as its tie-break.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title FROM nodes
|
||||
WHERE owner_id = ?1 AND kind = 'project' AND tombstoned = 0
|
||||
ORDER BY created_at, id",
|
||||
)?;
|
||||
let projects: Vec<(String, String)> = stmt
|
||||
.query_map((owner,), |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
// 1. Exact, case-sensitive.
|
||||
if let Some((id, _)) = projects.iter().find(|(_, title)| title == name) {
|
||||
return Ok(Some(id.clone()));
|
||||
}
|
||||
// 2. Case-insensitive exact — only when unambiguous.
|
||||
let ci: Vec<&String> = projects
|
||||
.iter()
|
||||
.filter(|(_, title)| title.eq_ignore_ascii_case(name))
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
if let [only] = ci.as_slice() {
|
||||
return Ok(Some((*only).clone()));
|
||||
}
|
||||
if ci.len() > 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
// 3. Case-insensitive prefix — only when unambiguous.
|
||||
let needle = name.to_ascii_lowercase();
|
||||
let pre: Vec<&String> = projects
|
||||
.iter()
|
||||
.filter(|(_, title)| title.to_ascii_lowercase().starts_with(&needle))
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
if let [only] = pre.as_slice() {
|
||||
return Ok(Some((*only).clone()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Every project node id in the subtree rooted at `root` (inclusive): `root`
|
||||
|
|
|
|||
|
|
@ -286,6 +286,13 @@ impl Store for LocalStore {
|
|||
tasks::project_scope(&self.conn, &self.owner_id, name)
|
||||
}
|
||||
|
||||
fn resolve_project(&self, name: &str) -> Result<Option<Node>> {
|
||||
match links::resolve_project_id(&self.conn, &self.owner_id, name)? {
|
||||
Some(id) => nodes::get(&self.conn, &id),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn health(&self) -> Result<Health> {
|
||||
tasks::health(&self.conn, &self.owner_id)
|
||||
}
|
||||
|
|
@ -491,6 +498,53 @@ mod tests {
|
|||
assert!(store.project_scope("Nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_project_is_fuzzy_only_when_unambiguous() {
|
||||
use crate::model::{NewNode, NodeKind};
|
||||
let mut store = store_at(1);
|
||||
let mk = |store: &mut LocalStore, title: &str| {
|
||||
store
|
||||
.create_node(NewNode {
|
||||
kind: NodeKind::Project,
|
||||
title: title.into(),
|
||||
body: None,
|
||||
})
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
let hephaestus = mk(&mut store, "Hephaestus");
|
||||
let garden = mk(&mut store, "Garden");
|
||||
|
||||
let id = |o: Option<Node>| o.map(|n| n.id);
|
||||
|
||||
// Exact (case-sensitive) wins.
|
||||
assert_eq!(
|
||||
id(store.resolve_project("Hephaestus").unwrap()),
|
||||
Some(hephaestus.clone())
|
||||
);
|
||||
// Case-insensitive exact.
|
||||
assert_eq!(
|
||||
id(store.resolve_project("hephaestus").unwrap()),
|
||||
Some(hephaestus.clone())
|
||||
);
|
||||
// Unambiguous prefix.
|
||||
assert_eq!(
|
||||
id(store.resolve_project("heph").unwrap()),
|
||||
Some(hephaestus.clone())
|
||||
);
|
||||
assert_eq!(id(store.resolve_project("gar").unwrap()), Some(garden));
|
||||
// No match at all.
|
||||
assert_eq!(id(store.resolve_project("nope").unwrap()), None);
|
||||
|
||||
// An exact (case-sensitive) title still wins even when it is a prefix of
|
||||
// another project — exactness beats fuzziness, never ambiguous.
|
||||
let work = mk(&mut store, "Work");
|
||||
let _workshop = mk(&mut store, "Workshop");
|
||||
assert_eq!(id(store.resolve_project("Work").unwrap()), Some(work));
|
||||
// …but an ambiguous prefix with no exact match resolves to nothing.
|
||||
assert_eq!(id(store.resolve_project("Wor").unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_project_unfiles_its_tasks_then_tombstones_it() {
|
||||
use crate::filter::ListFilter;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,13 @@ pub trait Store {
|
|||
/// `heph list --project <name>`. Errors if the name names no project.
|
||||
fn project_scope(&self, name: &str) -> Result<Vec<String>>;
|
||||
|
||||
/// Resolve a project NAME to its node, restricted to `project`-kind nodes
|
||||
/// (so a like-named task/doc never wins). Matching is case-insensitive and
|
||||
/// prefix-fuzzy when unambiguous (an exact title always wins outright).
|
||||
/// `None` if no project matches. Backs the `--project` CLI argument on
|
||||
/// `task`/`edit`/`promote` and project-parent resolution.
|
||||
fn resolve_project(&self, name: &str) -> Result<Option<Node>>;
|
||||
|
||||
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
|
||||
fn health(&self) -> Result<Health>;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ enum Action {
|
|||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")]
|
||||
#[command(
|
||||
name = "heph-tui",
|
||||
version = heph_core::VERSION,
|
||||
about = "Hephaestus task agenda / triage TUI"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the
|
||||
/// standard runtime path.
|
||||
|
|
|
|||
|
|
@ -160,6 +160,18 @@ enum Command {
|
|||
#[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.
|
||||
|
|
@ -577,6 +589,35 @@ fn main() -> Result<()> {
|
|||
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!(
|
||||
|
|
@ -787,23 +828,45 @@ fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result<Option<S
|
|||
recur.map(datespec::parse_recurrence).transpose()
|
||||
}
|
||||
|
||||
/// Resolve a project **name** to its node id, erroring if it isn't a project.
|
||||
/// 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("node.resolve", json!({ "title": name }))?;
|
||||
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)?;
|
||||
if node.kind.as_str() != "project" {
|
||||
bail!(
|
||||
"{name:?} resolves to a {} node, not a project",
|
||||
node.kind.as_str()
|
||||
);
|
||||
}
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -213,6 +213,10 @@ impl Store for RemoteStore {
|
|||
self.call_as("project.scope", json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn resolve_project(&self, name: &str) -> Result<Option<Node>> {
|
||||
self.call_as("project.resolve", json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn health(&self) -> Result<Health> {
|
||||
self.call_as("health", json!({}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -413,6 +413,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let p: ViewParams = parse(params)?;
|
||||
json!(store.project_scope(&p.name)?)
|
||||
}
|
||||
"project.resolve" => {
|
||||
let p: ViewParams = parse(params)?;
|
||||
json!(store.resolve_project(&p.name)?)
|
||||
}
|
||||
"version" => json!({ "version": heph_core::VERSION }),
|
||||
"health" => json!(store.health()?),
|
||||
"search" => {
|
||||
let p: SearchParams = parse(params)?;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,40 @@ fn node_resolve_is_exact_not_fuzzy_over_socket() {
|
|||
assert_eq!(missing, Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_rpc_returns_build_version() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let got = c.call("version", json!({})).unwrap();
|
||||
assert_eq!(got["version"], heph_core::VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_resolve_is_case_insensitive_and_prefix_fuzzy_over_socket() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let proj = c
|
||||
.call(
|
||||
"node.create",
|
||||
json!({ "kind": "project", "title": "Hephaestus" }),
|
||||
)
|
||||
.unwrap();
|
||||
let pid = proj["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Case-insensitive exact and unambiguous prefix both resolve to the project.
|
||||
for name in ["Hephaestus", "hephaestus", "heph"] {
|
||||
let got = c.call("project.resolve", json!({ "name": name })).unwrap();
|
||||
assert_eq!(got["id"], pid, "resolving {name:?}");
|
||||
}
|
||||
// An unknown name is JSON null, not an error.
|
||||
let missing = c
|
||||
.call("project.resolve", json!({ "name": "nope" }))
|
||||
.unwrap();
|
||||
assert_eq!(missing, Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_create_appears_in_next_with_context_link() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
|
|
|
|||
1
docs/changelog.d/heph-context-command.feature.md
Normal file
1
docs/changelog.d/heph-context-command.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
New `heph context <task-id>` command reads or edits a task's canonical-context doc body **by task id**, with no manual `canonical_context_id` lookup. With no flag it prints the body; `--body <text>` replaces it (`-` reads stdin, like `node update`); `--append <text>` adds a blank-line-separated paragraph. Errors clearly on a node that has no canonical-context doc (e.g. a plain doc, not a task).
|
||||
1
docs/changelog.d/heph-tui-version.feature.md
Normal file
1
docs/changelog.d/heph-tui-version.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
`heph-tui --version` now reports the version plus build commit (e.g. `1.0.0 (ab6701d12)`), matching `heph` and `hephd`. All three daily-driver binaries answer `--version` consistently.
|
||||
1
docs/changelog.d/prek-fmt-prepush.infra.md
Normal file
1
docs/changelog.d/prek-fmt-prepush.infra.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added a `cargo-fmt-check` pre-push prek hook that runs `cargo fmt --all --check` (mirroring CI's Dagger `check` step) whenever a push touches a `.rs` file. The pre-commit `cargo-fmt` hook reformats in place, but only fires when installed and run; the pre-push check is a last-line guard so an unformatted commit can't reach the runner. Run `prek install --hook-type pre-push` to activate it.
|
||||
1
docs/changelog.d/project-arg-fuzzy.feature.md
Normal file
1
docs/changelog.d/project-arg-fuzzy.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
`--project <name>` is now case-insensitive and prefix-fuzzy when unambiguous, across `heph task`, `heph edit`, `heph promote`, `heph list`, and project-parent resolution. `--project heph` or `--project hephaestus` both resolve `Hephaestus`. An exact (case-sensitive) title always wins outright, and an ambiguous prefix (e.g. `Wor` matching both `Work` and `Workshop`) resolves to nothing rather than silently picking one. A new `project.resolve` RPC backs the shared resolver.
|
||||
1
docs/changelog.d/version-rpc.feature.md
Normal file
1
docs/changelog.d/version-rpc.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
New `version` RPC returns the daemon's build version (`heph_core::VERSION`, e.g. `1.0.0 (ab6701d12)`), so RPC clients — notably the `hephaestus.nvim` plugin's `:Heph version` command — can report which `hephd` they are talking to without shelling out to the binary.
|
||||
16
prek.toml
16
prek.toml
|
|
@ -102,6 +102,22 @@ language = "system"
|
|||
files = '\.rs$'
|
||||
pass_filenames = false
|
||||
|
||||
# Pre-push safety net. The pre-commit cargo-fmt hook above reformats in place,
|
||||
# but only when it is installed and actually runs — a commit made before
|
||||
# `prek install`, or via a tool that skips hooks, can still carry unformatted
|
||||
# Rust to CI (where the Dagger `check` step runs `cargo fmt --all --check` and
|
||||
# fails). This hook re-checks at push time, mirroring CI byte-for-byte, so an
|
||||
# unformatted commit is blocked locally before it can reach the runner. It runs
|
||||
# only when the push range touches a .rs file, so Rust-free pushes pay nothing.
|
||||
[[repos.hooks]]
|
||||
id = "cargo-fmt-check"
|
||||
name = "cargo-fmt-check"
|
||||
entry = "cargo fmt --all --check"
|
||||
language = "system"
|
||||
files = '\.rs$'
|
||||
pass_filenames = false
|
||||
stages = ["pre-push"]
|
||||
|
||||
# GitHub/Forgejo Actions workflow linting
|
||||
[[repos]]
|
||||
repo = "https://github.com/rhysd/actionlint"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue