diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 0783e16..f3e7e10 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -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> { - 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::>>()?; + + // 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` diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index ebd01af..28d73b5 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -286,6 +286,13 @@ impl Store for LocalStore { tasks::project_scope(&self.conn, &self.owner_id, name) } + fn resolve_project(&self, name: &str) -> Result> { + 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 { 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| 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; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 3fd8275..72e13d3 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -132,6 +132,13 @@ pub trait Store { /// `heph list --project `. Errors if the name names no project. fn project_scope(&self, name: &str) -> Result>; + /// 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>; + /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 735bfe1..d7c123b 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -787,20 +787,16 @@ fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result) -> Result> { 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)) } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index c486055..43f8ad4 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -213,6 +213,10 @@ impl Store for RemoteStore { self.call_as("project.scope", json!({ "name": name })) } + fn resolve_project(&self, name: &str) -> Result> { + self.call_as("project.resolve", json!({ "name": name })) + } + fn health(&self) -> Result { self.call_as("health", json!({})) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 5f9a59c..5a46cd8 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -413,6 +413,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: ViewParams = parse(params)?; + json!(store.resolve_project(&p.name)?) + } "health" => json!(store.health()?), "search" => { let p: SearchParams = parse(params)?; diff --git a/docs/changelog.d/project-arg-fuzzy.feature.md b/docs/changelog.d/project-arg-fuzzy.feature.md new file mode 100644 index 0000000..76b1a4f --- /dev/null +++ b/docs/changelog.d/project-arg-fuzzy.feature.md @@ -0,0 +1 @@ +`--project ` 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.