feat: --project arg is case-insensitive / prefix-fuzzy when unambiguous

The `--project <name>` argument matched titles case-sensitively and
exactly, so `--project hephaestus` or `--project heph` failed against a
`Hephaestus` project. Make project-name resolution forgiving but
deterministic, via a tiered match in `resolve_project_id`:

  1. exact (case-sensitive) — the historical behavior; always wins
  2. case-insensitive exact — only when unambiguous
  3. case-insensitive prefix — only when unambiguous

Ambiguous fuzzy matches resolve to None (callers report "no project
named X") rather than silently picking one. This single resolver already
backed `heph list --project` (via project_scope); route the CLI's
task/edit/promote/parent path through it too with a new `project.resolve`
RPC + `Store::resolve_project`, so every `--project` surface behaves the
same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 10:57:37 -07:00
commit fc25f6ac51
7 changed files with 129 additions and 18 deletions

View file

@ -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`

View file

@ -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;

View file

@ -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>;

View file

@ -787,20 +787,16 @@ 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))
}

View file

@ -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!({}))
}

View file

@ -413,6 +413,10 @@ 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)?)
}
"health" => json!(store.health()?),
"search" => {
let p: SearchParams = parse(params)?;

View 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.