diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index d409544..a89ec41 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -128,3 +128,5 @@ {"schema":2,"kind":"decision","id":"d128","text":"Ledger records accept field-level corrections that keep the record's id.","state":"adopted","ts":"2026-09-23T08:17:31+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/**","docs/ledger.md"],"rationale":"Supersession changes the id, so records that support, depend on, or cite the old id point at a retired record. d88 restating d38 left d39 on stale support. Three one-off scripts already rewrote history in place with no trail.","supports":[],"depends_on":[],"answers":[],"supersedes":["d89"],"evidence":[],"revisit":"","cost_if_wrong":"A new line kind changes the ledger format, the projection every reader uses, and the revision hash.","pinned":false,"choice":"A correction is its own ledger line, addressed as ., that changes wording and metadata of an earlier record: text, rationale, scope, cost, evidence, revisit. Relations, state, kind, and provenance stay fixed; changing those still goes through supersession.","alternatives":["Supersede a mis-recorded record with a restatement.","Add a restatement flag to supersession that moves inbound links to the new record."],"decided_by":""} {"schema":2,"kind":"decision","id":"d129","text":"Corrections are ledger lines that project() folds into the corrected record.","state":"adopted","ts":"2026-09-23T08:20:41+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/ledger.py","docket/rebase.py","docket/context_delta.py","docket/cli/admin.py"],"rationale":"project() already runs after any history slice, so show --at and --since see only earlier corrections without their own change. A side file doubles merge, rebase, and hashing; folding in read() hides the original lines from rebase and check.","supports":[["d128"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Every raw-line reader must accept a fourth line kind, and older docket releases refuse a ledger holding one.","pinned":false,"choice":"A correction line (kind correction, id .) names one earlier record and a fields object of replacement values. project() applies corrections in file order, drops the lines, and adds corrections and original to the record. choice stays fixed; alternatives, decided_by, and pinned join the correctable fields.","alternatives":["A side file, .docket/corrections.jsonl, like the feature store.","Fold corrections inside read(), so raw consumers never see a correction line."],"decided_by":""} {"schema":2,"kind":"decision","id":"d130","text":"A correction appended with its id already set skips the write-time refusals.","state":"adopted","ts":"2026-09-23T09:15:42+00:00","author":"claude-code","session":"","branch":"feat/ledger-corrections","scope":["docket/ledger.py","docket/corrections.py","docket/rebase.py"],"rationale":"A correction valid on its own branch can be refused against the other branch's corrected state, which leaves a rebase half-applied.","supports":[["d129"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A hand-written numbered correction line bypasses the refusals; validation still bounds its fields.","pinned":false,"choice":"append runs the no-op drop and the echo and question-text refusals only for a correction the CLI submits unnumbered. A pre-numbered correction, as rebase appends, passes on validation alone, the same as a pre-numbered record.","alternatives":["Run the refusals on every appended correction, including rebased ones."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d131","text":"Ledger filtering uses one query language parsed in Python, and the viewer calls the CLI to apply it.","state":"adopted","ts":"2026-09-23T10:15:32+00:00","author":"claude-code","session":"","branch":"main","scope":["graph/**","docket/cli/graph.py","docket/cli/query.py","docket/where.py"],"rationale":"One parser keeps the CLI and viewer in agreement and keeps filtering in Python, where the viewer decision put it. A submitted filter costs one Python start, about 150 ms.","supports":[["d36"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Live filtering on field terms would need a Go parser later, and the viewer's layout change moves every key hint users know.","pinned":false,"choice":"docket/where.py parses field:value terms (kind, state, scope, is, author, branch, after, before) plus bare text. list and graph take --where. The viewer sends a submitted query to a hidden docket _filter-ids command and keeps the returned ids; bare words still filter live in Go. The viewer gains a three-line bottom pane for filter, status, and key hints, with a ? help overlay.","alternatives":["Parse the query in both Python and Go, kept in step by a shared fixture table.","Python pre-computes matchable fields and Go runs a reduced matcher.","Toggle keys or a filter form in the viewer instead of a query prompt."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d132","text":"The graph viewer receives its filter callback in the DOCKET_GRAPH_FILTER_CMD environment variable.","state":"adopted","ts":"2026-09-23T14:02:55+00:00","author":"claude-code","session":"","branch":"feat/ledger-filtering","scope":["graph/main.go","docket/cli/graph.py"],"rationale":"The viewer binary ships separately from the CLI. An older binary exits 2 on an unknown flag and ignores an unknown variable, so the variable keeps docket graph working across an update skew.","supports":[["d131"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"The callback is invisible in the viewer's --help; anyone running the binary by hand must know the variable.","pinned":false,"choice":"docket graph sets DOCKET_GRAPH_FILTER_CMD to a JSON argv ([python, bin/docket, _filter-ids, --]) for the viewer process; the viewer appends the query as one argument.","alternatives":["Pass the argv as a --filter-cmd flag, as the spec first named it."],"decided_by":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index 63900d6..5093d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `docket list --where` and `docket graph --where` take a query language: plain words, `kind:`, `state:`, `scope:PATH`, `is:pinned|corrected|retired|blocked`, `author:`, `branch:`, `after:`, and `before:`, with `-` to negate a term. `docs/commands.md` lists the rules. +- The graph viewer filters with the same language. It previews text terms as you type and asks the CLI about field terms when you press `Enter`. A bottom pane shows the filter, the match count, the sort, and the key hints, and `?` opens a help overlay. + +### Changed + +- The graph viewer's `/` searches the id, text, choice, and rationale, and ANDs its words. Before, it matched one substring across every field. The `LEDGER GRAPH` title and the separate search line are gone. + ## [0.19.0] - 2026-09-23 ### Added diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index ff10c01..ae61ee0 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -11,10 +11,11 @@ from docket.cli.correct import add_correct_parser from docket.cli.feature_parser import add_feature_parser from docket.cli.graph import cmd_graph -from docket.cli.query import cmd_list, cmd_show, cmd_where +from docket.cli.query import cmd_filter_ids, cmd_list, cmd_show, cmd_where from docket.cli.record import cmd_claim, cmd_decision, cmd_question from docket.cli.selfupdate import cmd_update, cmd_update_fetch from docket.ledger import KINDS, STATES, LedgerError +from docket.where import WhereError class VersionAction(argparse.Action): @@ -87,6 +88,7 @@ def main(argv: list[str] | None = None) -> int: "--state", choices=tuple(sorted({state for values in STATES.values() for state in values})) ) ls.add_argument("--find", help="match question or answer text") + ls.add_argument("--where", metavar="QUERY", help="filter with the query language") ls.add_argument( "--superseded", action="store_true", @@ -105,6 +107,7 @@ def main(argv: list[str] | None = None) -> int: "--state", choices=tuple(sorted({state for values in STATES.values() for state in values})) ) gr.add_argument("--find", help="match question or answer text") + gr.add_argument("--where", metavar="QUERY", help="filter with the query language") gr.add_argument( "--format", choices=("mermaid", "dot", "csv"), @@ -243,6 +246,10 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("_update-fetch").set_defaults(func=cmd_update_fetch) + fi = sub.add_parser("_filter-ids") + fi.add_argument("query", nargs=argparse.REMAINDER) + fi.set_defaults(func=cmd_filter_ids) + args = p.parse_args(argv) if args.cmd is None: print(f"docket {version()}") @@ -261,6 +268,6 @@ def main(argv: list[str] | None = None) -> int: try: return args.func(args) - except (LedgerError, features.FeatureError, OutcomeError, OSError) as exc: + except (LedgerError, features.FeatureError, OutcomeError, WhereError, OSError) as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/docket/cli/completion.py b/docket/cli/completion.py index be437e4..6f38eeb 100644 --- a/docket/cli/completion.py +++ b/docket/cli/completion.py @@ -35,6 +35,7 @@ "--at", "--all", "--find", + "--where", "--superseded", "--oneline", "--json", diff --git a/docket/cli/graph.py b/docket/cli/graph.py index bac2bfe..1f0c7c0 100644 --- a/docket/cli/graph.py +++ b/docket/cli/graph.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from docket import ROOT, env +from docket import ROOT, env, where from docket.cli.term import ( _DIM, _GRAPH_GLYPHS, @@ -124,22 +124,44 @@ def _graph_payload(entries: list[dict], retired: dict[str, str]) -> dict: return graph_payload(entries) -def _run_graph_viewer(entries: list[dict], retired: dict[str, str], pretty: bool = False) -> int: +def _filter_command() -> list[str]: + """The argv the viewer runs for a query with a field term. + + The viewer appends the query as one final argument. sys.executable names + the interpreter, so this also runs on Windows, where bin/docket cannot be + executed by itself. + """ + return [sys.executable, str(ROOT / "bin" / "docket"), "_filter-ids", "--"] + + +def _run_graph_viewer( + entries: list[dict], + retired: dict[str, str], + pretty: bool = False, + where_text: str = "", + ids: list[str] | None = None, +) -> int: """Run the native viewer with an inherited terminal and private input.""" viewer = _graph_viewer_path() temp_path: Path | None = None try: fd, raw_path = tempfile.mkstemp(prefix="docket-graph-", suffix=".json") temp_path = Path(raw_path) + payload = _graph_payload(entries, retired) + if where_text: + payload["filter"] = {"query": where_text, "ids": ids or []} with os.fdopen(fd, "w", encoding="utf-8") as data_file: - json.dump(_graph_payload(entries, retired), data_file) + json.dump(payload, data_file) data_file.write("\n") try: command = [str(viewer), "--data", str(temp_path)] if pretty: command.append("--pretty") - result = subprocess.run(command) + # An older viewer binary exits on an unknown flag but ignores an + # unknown variable, so the callback travels in the environment. + child_env = {**os.environ, "DOCKET_GRAPH_FILTER_CMD": json.dumps(_filter_command())} + result = subprocess.run(command, env=child_env) except KeyboardInterrupt: return 130 except OSError as exc: @@ -441,18 +463,23 @@ def _render_graph( return 0 -def _write_csv(entries: list[dict], args: argparse.Namespace) -> int: +def _render_static( + entries: list[dict], retired: dict[str, str], args: argparse.Namespace, style: str +) -> int: + if not entries: + print("docket: nothing recorded") + return 0 + return _render_graph(entries, retired, args, style) + + +def _write_csv(entries: list[dict], args: argparse.Namespace, superseded: bool) -> int: """Write nodes.csv and edges.csv for Gephi into args.out.""" from pathlib import Path from docket.graph_export import to_csv - nodes, edges = to_csv( - entries, - detail=args.detail, - superseded=bool(getattr(args, "superseded", False)), - ) + nodes, edges = to_csv(entries, detail=args.detail, superseded=superseded) if not nodes: print("docket: no record in this selection carries a relation", file=sys.stderr) return 0 @@ -499,6 +526,8 @@ def cmd_graph(args: argparse.Namespace) -> int: # two documents and stdout cannot carry both. print("docket: --format csv writes two files; name a directory with --out", file=sys.stderr) return 2 + where_text = getattr(args, "where", None) or "" + query = where.parse(where_text) if interactive and not _graph_is_tty(): print("docket: --interactive requires terminal stdin and stdout", file=sys.stderr) return 1 @@ -507,20 +536,17 @@ def cmd_graph(args: argparse.Namespace) -> int: if not entries: print("docket: nothing recorded") return 0 + shown = [e for e in entries if query.matches(e)] + superseded = bool(getattr(args, "superseded", False)) or query.wants_retired if fmt == "csv": - return _write_csv(entries, args) + return _write_csv(shown, args, superseded) if fmt: from docket.graph_export import to_dot, to_mermaid render = to_dot if args.format == "dot" else to_mermaid - text = render( - entries, - detail=args.detail, - direction=args.direction, - superseded=bool(getattr(args, "superseded", False)), - ) + text = render(shown, detail=args.detail, direction=args.direction, superseded=superseded) if not text: print("docket: no record in this selection carries a relation", file=sys.stderr) return 0 @@ -535,7 +561,15 @@ def cmd_graph(args: argparse.Namespace) -> int: _graph_viewer_error(viewer) if interactive: return 1 - return _render_graph(entries, retired, args, "compact") - return _run_graph_viewer(entries, retired, pretty=bool(getattr(args, "pretty", False))) + return _render_static(shown, retired, args, "compact") + # The viewer gets every record the other flags allow, so clearing its + # filter brings them back; --where travels as its initial filter. + return _run_graph_viewer( + entries, + retired, + pretty=bool(getattr(args, "pretty", False)), + where_text=where_text, + ids=[e["id"] for e in shown], + ) - return _render_graph(entries, retired, args, style or "forest") + return _render_static(shown, retired, args, style or "forest") diff --git a/docket/cli/query.py b/docket/cli/query.py index 40d0969..a347517 100644 --- a/docket/cli/query.py +++ b/docket/cli/query.py @@ -12,7 +12,7 @@ import sys import textwrap -from docket import corrections, env +from docket import corrections, env, where from docket.cli.term import _DIM, _STATE_COLOR, _c, _match, _use_color from docket.context_model import positions from docket.env import LEDGER, justification_sets, read, retired_by @@ -36,9 +36,10 @@ def _list_dim_tail(line: str, marker: str, use_color: bool) -> str: def cmd_list(args: argparse.Namespace) -> int: + query = where.parse(getattr(args, "where", None) or "") entries = project(read(env.ledger_path()), validated=True) retired = retired_by(entries) - if not args.superseded: + if not args.superseded and not query.wants_retired: entries = [e for e in entries if e.get("id") not in retired] if args.state: entries = [e for e in entries if e.get("state") == args.state] @@ -46,6 +47,7 @@ def cmd_list(args: argparse.Namespace) -> int: entries = [e for e in entries if e.get("kind") == args.kind] if args.find: entries = [e for e in entries if _match(e, args.find)] + entries = [e for e in entries if query.matches(e)] if not entries: if getattr(args, "json", False): print("[]") @@ -205,4 +207,21 @@ def cmd_where(args: argparse.Namespace) -> int: return 0 -__all__ = ["cmd_list", "cmd_show", "cmd_where"] +def cmd_filter_ids(args: argparse.Namespace) -> int: + """Print the id of every record the query matches, retired included. + + The graph viewer runs this for a query with a field term and keeps the + rows whose ids it prints. + """ + parts = list(args.query) + # argparse keeps the "--" separator at the head of a REMAINDER list. + if parts[:1] == ["--"]: + parts = parts[1:] + query = where.parse(" ".join(parts)) + for e in project(read(env.ledger_path()), validated=True): + if query.matches(e): + print(e["id"]) + return 0 + + +__all__ = ["cmd_filter_ids", "cmd_list", "cmd_show", "cmd_where"] diff --git a/docket/where.py b/docket/where.py new file mode 100644 index 0000000..125516c --- /dev/null +++ b/docket/where.py @@ -0,0 +1,214 @@ +"""The query language behind --where and the graph viewer's filter. + +`docket list`, `docket graph`, and the viewer's callback `docket _filter-ids` +all parse here, so a query means the same thing everywhere. The viewer repeats +only the tokenizer and the text term, in graph/filter.go; a change to either +must land in both files. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, datetime, timezone +from typing import Any + +from docket.config import DEFAULTS +from docket.context_model import _list, _normalize_path, scope_strength +from docket.ledger import KINDS, STATES + +FIELDS = ("after", "author", "before", "branch", "is", "kind", "scope", "state") +IS_VALUES = ("blocked", "corrected", "pinned", "retired") +STATE_VALUES = tuple(sorted({state for values in STATES.values() for state in values})) +_TEXT_KEYS = ("id", "text", "choice", "rationale") +_FIELD_RE = re.compile(r"[A-Za-z]+") +_DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") +# The defaults, never the configured weights: a weight set to zero in +# config.toml would silently turn scope matching off. +_WEIGHTS = DEFAULTS["weights"] + + +class WhereError(ValueError): + """A query that names an unknown field or value, or a date not in YYYY-MM-DD.""" + + +@dataclass(frozen=True) +class Term: + field: str # empty for a text term + value: str + negated: bool + + +@dataclass(frozen=True) +class Query: + terms: tuple[Term, ...] = () + + @property + def wants_retired(self) -> bool: + return any(t.field == "is" and t.value == "retired" and not t.negated for t in self.terms) + + @property + def has_fields(self) -> bool: + return any(t.field for t in self.terms) + + def matches(self, entry: Mapping[str, Any]) -> bool: + groups: dict[str, bool] = {} + for term in self.terms: + hit = _hit(term, entry) + if term.negated: + if hit: + return False + elif term.field in ("", "is"): + if not hit: + return False + else: + groups[term.field] = groups.get(term.field, False) or hit + return all(groups.values()) + + +def parse(query: str) -> Query: + return Query(tuple(_term(negated, body, colon) for negated, body, colon in _split(query))) + + +def _split(query: str) -> list[tuple[bool, str, int]]: + """Each term as (negated, body, colon). + + Quotes are removed from body and may hold whitespace; an unclosed quote + runs to the end. colon indexes the first ':' that comes before any quote, + or is -1, which keeps `"d12:"` a text term. + """ + parts: list[tuple[bool, str, int]] = [] + i, n = 0, len(query) + while i < n: + if query[i].isspace(): + i += 1 + continue + negated = query[i] == "-" and i + 1 < n and not query[i + 1].isspace() + if negated: + i += 1 + body, colon, quoted = "", -1, False + while i < n and not query[i].isspace(): + if query[i] == '"': + quoted = True + end = query.find('"', i + 1) + end = n if end == -1 else end + body += query[i + 1 : end] + i = end + 1 + continue + if query[i] == ":" and colon == -1 and not quoted: + colon = len(body) + body += query[i] + i += 1 + parts.append((negated, body, colon)) + return parts + + +def _term(negated: bool, body: str, colon: int) -> Term: + if colon <= 0 or not _FIELD_RE.fullmatch(body[:colon]): + # str.lower is Python's own case folding, not Go's strings.ToLower. + # They disagree on İ (Turkish dotted capital I) and a word-final Σ, so + # the same text term can match differently in graph/filter.go. + return Term("", body.lower(), negated) + field, value = body[:colon].lower(), body[colon + 1 :] + shown = ("-" if negated else "") + body + if field not in FIELDS: + raise _fail( + shown, + f"unknown field {field}; use {', '.join(FIELDS)}, or quote the term to search text", + ) + if not value: + raise _fail(shown, "needs a value") + if field in ("after", "before"): + _day(shown, value) + return Term(field, value, negated) + if field == "scope": + return Term(field, value, negated) + value = value.lower() + if field == "kind" and value not in KINDS: + raise _fail(shown, f"unknown kind {value}; use {', '.join(KINDS)}") + if field == "state" and value in ("retired", "blocked"): + raise _fail(shown, f"use is:{value}") + if field == "state" and value not in STATE_VALUES: + raise _fail(shown, f"unknown state {value}; use {', '.join(STATE_VALUES)}") + if field == "is" and value not in IS_VALUES: + raise _fail(shown, f"unknown is:{value}; use {', '.join(IS_VALUES)}") + return Term(field, value, negated) + + +def _fail(term: str, message: str) -> WhereError: + return WhereError(f"docket: where: {term}: {message}") + + +def _day(term: str, value: str) -> date: + if _DATE_RE.fullmatch(value): + try: + return date.fromisoformat(value) + except ValueError: + pass + raise _fail(term, "dates are YYYY-MM-DD") + + +def _hit(term: Term, entry: Mapping[str, Any]) -> bool: + field, value = term.field, term.value + if not field: + return any(value in str(entry.get(key) or "").lower() for key in _TEXT_KEYS) + if field in ("kind", "state"): + return str(entry.get(field) or "").lower() == value + if field in ("author", "branch"): + return value in str(entry.get(field) or "").lower() + if field == "is": + return _is(value, entry) + if field == "scope": + return _scope(value, entry) + day = _ts_day(entry.get("ts")) + if day is None: + return False + limit = date.fromisoformat(value) + return day >= limit if field == "after" else day < limit + + +def _is(value: str, entry: Mapping[str, Any]) -> bool: + if value == "pinned": + return bool(entry.get("pinned")) + if value == "corrected": + return bool(entry.get("corrections")) + if value == "retired": + return bool(entry.get("retired_by")) + # The viewer's rule, graph/model.go decisionCondition. blocked_by cannot + # decide it: project() sets blocked_by to the record's own id for every + # retired or revoked decision. + return ( + entry.get("kind") == "decision" + and entry.get("recorded_state", entry.get("state")) == "adopted" + and not entry.get("retired_by") + and entry.get("applicable") is False + ) + + +def _scope(value: str, entry: Mapping[str, Any]) -> bool: + path = _normalize_path(value) + scopes = [_normalize_path(item) for item in _list(entry.get("scope"))] + if path.endswith("/"): + folder = path.rstrip("/") + if any(item.startswith(path) or item == folder for item in scopes): + return True + return scope_strength(entry, (folder,), _WEIGHTS) > 0 + # scope_strength skips a top-level entry with no "/" and no glob, so a + # file such as README.md needs the equality test. + return path in scopes or scope_strength(entry, (path,), _WEIGHTS) > 0 + + +def _ts_day(raw: Any) -> date | None: + if not isinstance(raw, str) or not raw: + return None + try: + stamp = datetime.fromisoformat(raw) + except ValueError: + return None + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + return stamp.astimezone(timezone.utc).date() + + +__all__ = ["FIELDS", "IS_VALUES", "Query", "STATE_VALUES", "Term", "WhereError", "parse"] diff --git a/docs/commands.md b/docs/commands.md index e03bd57..13170b1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -81,6 +81,7 @@ show ID.N` prints one correction with the values it replaced. | `--kind K` | Only `claim`, `decision`, or `question` | | `--state S` | Only records in that state | | `--find TEXT` | Match record text or a decision's choice, ignoring case | +| `--where QUERY` | Filter with the [query language](#query-language); ANDs with the other flags | | `--superseded` | Include records a later one retired | | `--oneline` | One line per record | | `--json` | Print records as JSON | @@ -99,6 +100,7 @@ show ID.N` prints one correction with the values it replaced. |---|---| | `--style forest\|rail\|compact` | Static layout | | `--kind`, `--state`, `--find` | Filter, as in `list` | +| `--where QUERY` | Filter with the [query language](#query-language). In the viewer it is the initial filter, and clearing it shows every record. | | `--interactive`, `--no-interactive` | Require or refuse the native viewer | | `--plain`, `--pretty` | Force colour off or on | | `--format mermaid\|dot\|csv` | Export the graph as a mermaid flowchart, a graphviz digraph, or Gephi tables | @@ -107,6 +109,42 @@ show ID.N` prints one correction with the values it replaced. | `--detail N` | With `--format`, characters of text per node. Default 40, `0` for IDs alone. | | `--direction LR\|TD\|RL\|BT` | With `--format mermaid` or `dot`, the layout direction. Default `LR`. | +### Query language + +`--where` takes one query. The graph viewer's `/` input takes the same one. + + docket list --where 'kind:decision -is:retired scope:docket/ledger.py cache' + +| Term | Matches when | +|---|---| +| `word` | the id, text, choice, or rationale contains it | +| `kind:K` | the kind is `claim`, `decision`, or `question` | +| `state:S` | the effective state is S; `state:resolved` finds answered questions | +| `scope:PATH` | a scope entry governs the file PATH, as `docket context --file` scores it | +| `scope:DIR/` | a scope entry starts with `DIR/`, or governs the directory | +| `is:pinned`, `is:corrected`, `is:retired`, `is:blocked` | the record is pinned, corrected, or retired, or is a blocked decision | +| `author:A`, `branch:B` | the author or the branch contains the value | +| `after:D`, `before:D` | the record's UTC date is on or after D, or before D; D is `YYYY-MM-DD` | + +- Spaces separate terms. Double quotes hold spaces: `author:"a teammate"`. +- A quoted term is always text: `"d12:"` searches for the characters `d12:`. +- A leading `-` negates one term. +- Text terms AND each other, and `is:` terms AND each other. Repeats of any + other field OR each other: `kind:claim kind:decision`. The groups AND. +- Case is ignored. An empty query matches everything. +- `scope:` takes a path, not a pattern. `scope:graph/**` asks which records + govern a file named `graph/**`. Use `scope:graph/` for a directory. +- A retired record keeps its state, so `state:adopted` includes superseded + decisions. `list` hides retired records unless you give `--superseded` or + the query holds `is:retired`. +- A query that starts with `-` needs `=`: `--where=-kind:question`. argparse + reads a separate `-kind:question` as an option. +- An export draws only records linked to another record in the selection. A + narrow query can leave none, and the command then prints `no record in this + selection carries a relation`. +- An unknown field or value, or a malformed date, exits 1 with a message that + lists the valid values. + `docket context` | Flag | Effect | diff --git a/docs/installer-reference.md b/docs/installer-reference.md index c0495f1..ce12069 100644 --- a/docs/installer-reference.md +++ b/docs/installer-reference.md @@ -185,14 +185,23 @@ detail pane. Use these keys: | `tab` | Switch between the tree and detail pane | | `s` | Cycle the sort field: ledger, id, timestamp, kind, state | | `r` | Reverse the sort direction | -| `/` | Search IDs, kinds, states, text, choices, and costs | +| `/` | Filter with the [query language](commands.md#query-language) | +| `?` | Show every key and the filter terms; any key closes it | | `q`/`Ctrl-C` | Quit | When the detail pane is focused, `PgUp`/`Ctrl-U` and `PgDn`/`Ctrl-D` scroll it; -`h`/`left` and `l`/`right` move horizontally. Press `Enter` to apply a search -and `Esc` to cancel it. A sort reorders roots only, and each subtree moves with -its root. The selection follows the record, not the row. The footer names the -active sort field and direction. +`h`/`left` and `l`/`right` move horizontally. A sort reorders roots only, and +each subtree moves with its root. The selection follows the record, not the +row. + +The bottom pane shows the applied filter, a count such as `12/130 match`, the +sort, and the key hints. While you type a filter, the tree previews its text +terms. `Enter` applies the filter, `Esc` restores the previous rows, and +`Enter` on an empty input clears the filter. A query with a field term, such +as `kind:decision` or `scope:graph/`, runs `docket _filter-ids` when you press +`Enter`, and the status line shows `filtering…` until it answers. If the query +is invalid, the previous rows stay and the status line shows the error. +`docket graph --where QUERY` opens the viewer with that filter applied. Piped output stays static. Use `--no-interactive` or `--plain` to force static output, or choose `--style forest`, `--style rail`, or `--style compact`. diff --git a/graph/filter.go b/graph/filter.go new file mode 100644 index 0000000..929f59a --- /dev/null +++ b/graph/filter.go @@ -0,0 +1,185 @@ +package main + +import ( + "strings" + "unicode" + + tea "charm.land/bubbletea/v2" +) + +// filterTerm is one term of a --where query. field is empty for a text term. +type filterTerm struct { + field string + value string + negated bool +} + +type filterQuery struct { + terms []filterTerm +} + +// parseFilter splits a query as docket/where.py _split and _term do, so a +// query with only text terms keeps the same rows here and in the CLI. +// tests/test_where.py SHARED_CASES and TestFilterTokenizerMatchesPython hold +// one table of cases. It never fails: field names and values are checked by +// the CLI when the query goes to the filter command. +func parseFilter(input string) filterQuery { + var q filterQuery + runes := []rune(input) + n := len(runes) + for i := 0; i < n; { + if unicode.IsSpace(runes[i]) { + i++ + continue + } + negated := runes[i] == '-' && i+1 < n && !unicode.IsSpace(runes[i+1]) + if negated { + i++ + } + var body []rune + colon, quoted := -1, false + for i < n && !unicode.IsSpace(runes[i]) { + if runes[i] == '"' { + quoted = true + end := i + 1 + for end < n && runes[end] != '"' { + end++ + } + body = append(body, runes[i+1:end]...) + i = end + 1 + continue + } + if runes[i] == ':' && colon == -1 && !quoted { + colon = len(body) + } + body = append(body, runes[i]) + i++ + } + q.terms = append(q.terms, newFilterTerm(negated, body, colon)) + } + return q +} + +func newFilterTerm(negated bool, body []rune, colon int) filterTerm { + if colon > 0 && asciiLetters(body[:colon]) { + return filterTerm{field: strings.ToLower(string(body[:colon])), value: string(body[colon+1:]), negated: negated} + } + // strings.ToLower is Go's own case folding, not Python's str.lower. They + // disagree on İ (Turkish dotted capital I) and a word-final Σ, so the same + // text term can match differently here than in docket/where.py. + return filterTerm{value: strings.ToLower(string(body)), negated: negated} +} + +func asciiLetters(runes []rune) bool { + for _, r := range runes { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} + +func (q filterQuery) hasFields() bool { + for _, t := range q.terms { + if t.field != "" { + return true + } + } + return false +} + +func (q filterQuery) hasText() bool { + for _, t := range q.terms { + if t.field == "" { + return true + } + } + return false +} + +// textMatches applies the text terms only, over the fields the CLI's text +// term reads: id, text, choice, and rationale. +func (q filterQuery) textMatches(e Entry) bool { + fields := []string{e.ID, e.Question, e.Choice, e.Rationale} + for _, t := range q.terms { + if t.field != "" { + continue + } + hit := false + for _, f := range fields { + if strings.Contains(strings.ToLower(f), t.value) { + hit = true + break + } + } + if hit == t.negated { + return false + } + } + return true +} + +// textSet is nil when the query has no text term, which shows every row. +func (m model) textSet(q filterQuery) map[string]bool { + if !q.hasText() { + return nil + } + set := make(map[string]bool) + for _, id := range m.order { + if q.textMatches(m.entries[id]) { + set[id] = true + } + } + return set +} + +func (m *model) previewFilter() { + id := m.selectedID() + m.shown = m.textSet(parseFilter(m.searchInput.Value())) + m.reselect(id) +} + +// submitFilter applies the text terms at once. A query with a field term +// also runs the filter command; until it answers, the tree keeps the text +// preview. Every submission takes a new sequence number, so a slow answer to +// an earlier one cannot overwrite a later filter. +func (m model) submitFilter() (model, tea.Cmd) { + id := m.selectedID() + value := strings.TrimSpace(sanitize(m.searchInput.Value())) + m.searching = false + m.searchInput.Blur() + m.filterSeq++ + q := parseFilter(value) + m.query = value + m.shown = m.textSet(q) + m.status, m.statusErr = "", false + var cmd tea.Cmd + if q.hasFields() && m.filterCmd == nil { + m.status, m.statusErr = "field filters need docket", true + } else if q.hasFields() { + m.pendingQuery = value + m.status = "filtering…" + cmd = runFilterCmd(m.filterCmd, value, m.filterSeq) + } + if cmd == nil { + // No callback is in flight, so this is the state on screen: an empty + // query, a text-only query, or a field query shown text-only for want + // of a filter command. Esc must be able to come back to it. + m.appliedShown, m.appliedQuery = m.shown, m.query + } + m.reselect(id) + return m, cmd +} + +// reselect keeps the cursor on id when the row is still visible after a +// filter change, and moves it to the first row otherwise. +func (m *model) reselect(id string) { + m.selected = 0 + for i, row := range m.visibleRows() { + if row.id == id { + m.selected = i + break + } + } + m.refreshDetail() +} diff --git a/graph/filtercmd.go b/graph/filtercmd.go new file mode 100644 index 0000000..527e950 --- /dev/null +++ b/graph/filtercmd.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "context" + "os/exec" + "strings" + "time" + + tea "charm.land/bubbletea/v2" +) + +// filterTimeout is a var, not a const, so tests can shorten it instead of +// waiting out the real 10 seconds. +var filterTimeout = 10 * time.Second + +// filterResultMsg carries the ids the filter command printed, or the reason +// it failed. seq ties it to the submission that started it. +type filterResultMsg struct { + seq int + ids []string + err string +} + +// runFilterCmd appends the query as one final argument, which the CLI's +// `_filter-ids -- QUERY` reads whole. Stdin is empty and both output streams +// are captured, so the child never writes to the viewer's terminal. +func runFilterCmd(argv []string, query string, seq int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), filterTimeout) + defer cancel() + args := append(append([]string(nil), argv[1:]...), query) + cmd := exec.CommandContext(ctx, argv[0], args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if ctx.Err() != nil { + return filterResultMsg{seq: seq, err: "filter timed out after " + filterTimeout.String()} + } + if err != nil { + return filterResultMsg{seq: seq, err: lastLine(stderr.String(), err.Error())} + } + return filterResultMsg{seq: seq, ids: strings.Fields(stdout.String())} + } +} + +func lastLine(text, fallback string) string { + lines := strings.Split(text, "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(sanitize(lines[i])); line != "" { + return line + } + } + return fallback +} + +// applyFilterResult drops a result from any submission but the latest. A +// result that lands while the input is open becomes the state Esc restores. +func (m model) applyFilterResult(msg filterResultMsg) model { + if msg.seq != m.filterSeq { + return m + } + id := m.selectedID() + if msg.err != "" { + m.status, m.statusErr = msg.err, true + // The applied state is the one thing still known good; restore Esc's + // target unconditionally, and the view itself once there is no input + // left open to preserve. + m.prevShown, m.prevQuery = m.appliedShown, m.appliedQuery + if !m.searching { + m.shown, m.query = m.appliedShown, m.appliedQuery + m.reselect(id) + } + return m + } + set := make(map[string]bool, len(msg.ids)) + for _, ident := range msg.ids { + set[ident] = true + } + m.status, m.statusErr = "", false + m.appliedShown, m.appliedQuery = set, m.pendingQuery + if m.searching { + m.prevShown, m.prevQuery = set, m.pendingQuery + return m + } + m.shown, m.query = set, m.pendingQuery + m.reselect(id) + return m +} diff --git a/graph/main.go b/graph/main.go index 69c673f..e3a877e 100644 --- a/graph/main.go +++ b/graph/main.go @@ -33,6 +33,12 @@ func main() { fmt.Fprintf(os.Stderr, "docket-graph: %v\n", err) os.Exit(2) } + // A flag would make an older viewer binary exit on the new CLI's launch. + filterCmd, err := parseFilterCmd(os.Getenv("DOCKET_GRAPH_FILTER_CMD")) + if err != nil { + fmt.Fprintf(os.Stderr, "docket-graph: %v\n", err) + os.Exit(2) + } if *plain || !isTerminal(os.Stdin) || !isTerminal(os.Stdout) { if err := renderPlain(os.Stdout, data); err != nil { fmt.Fprintf(os.Stderr, "docket-graph: %v\n", err) @@ -40,7 +46,7 @@ func main() { } return } - program := tea.NewProgram(newModel(data, *pretty || os.Getenv("NO_COLOR") == "")) + program := tea.NewProgram(newModelWithFilter(data, *pretty || os.Getenv("NO_COLOR") == "", filterCmd)) if _, err := program.Run(); err != nil { fmt.Fprintf(os.Stderr, "docket-graph: %v\n", err) os.Exit(1) @@ -72,6 +78,17 @@ func readData(path string) (GraphData, error) { return data, nil } +func parseFilterCmd(raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + var argv []string + if err := json.Unmarshal([]byte(raw), &argv); err != nil || len(argv) == 0 { + return nil, fmt.Errorf("DOCKET_GRAPH_FILTER_CMD must be a non-empty JSON array of strings") + } + return argv, nil +} + func renderPlain(w io.Writer, data GraphData) error { m := NewModel(data) for _, row := range m.rows { diff --git a/graph/main_test.go b/graph/main_test.go index fae03e1..a7b6ea0 100644 --- a/graph/main_test.go +++ b/graph/main_test.go @@ -1,10 +1,14 @@ package main import ( + "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "testing" + "time" "unicode" tea "charm.land/bubbletea/v2" @@ -372,11 +376,11 @@ func TestPlainOverviewShowsKindAndEffectiveState(t *testing.T) { } } -func TestLedgerGraphTitleAndBlockedStyle(t *testing.T) { +func TestTreeStartsOnTheFirstRowAndBlockedStyle(t *testing.T) { m := NewModel(testData()) view := ansi.Strip(m.View().Content) - if !strings.Contains(view, "LEDGER GRAPH") || strings.Contains(view, "DECISION GRAPH") { - t.Fatalf("graph title = %q", view) + if strings.Contains(view, "LEDGER GRAPH") || !strings.Contains(strings.Split(view, "\n")[0], "d1") { + t.Fatalf("the tree does not start on the first row: %q", view) } blocked := newModel(GraphData{Version: 2, Entries: []Entry{{ID: "d1", Kind: "decision", State: "adopted", Applicable: false}}}, true).stateStyle("blocked").Render("state") adopted := newModel(GraphData{Version: 2, Entries: []Entry{{ID: "d1", Kind: "decision", State: "adopted", Applicable: true}}}, true).stateStyle("adopted").Render("state") @@ -435,8 +439,8 @@ func TestSortReordersRootsAndKeepsSubtreesContiguous(t *testing.T) { } assertRowOrder(t, m, "b", "b1", "a", "a1", "a2") assertTreeIntact(t, m) - if !strings.Contains(ansi.Strip(m.footer()), "sort id desc") { - t.Fatalf("footer hid the active sort: %q", ansi.Strip(m.footer())) + if !strings.Contains(ansi.Strip(m.statusLine()), "sort id desc") { + t.Fatalf("status line hid the active sort: %q", ansi.Strip(m.statusLine())) } m, _ = updateModel(m, keyMsg('r')) @@ -496,6 +500,443 @@ func TestSelectionFollowsTheRecordAcrossASort(t *testing.T) { } } +// tokenizerCase and the JSON fixture keep the field names Python's +// tests/test_where.py reads under json.load, unlike filterTerm's Go-only names. +type tokenizerCase struct { + Input string `json:"input"` + Terms []struct { + Field string `json:"field"` + Value string `json:"value"` + Negated bool `json:"negated"` + } `json:"terms"` +} + +func TestFilterTokenizerMatchesPython(t *testing.T) { + // tests/test_where.py reads the same fixture. + data, err := os.ReadFile("testdata/tokenizer_cases.json") + if err != nil { + t.Fatal(err) + } + var cases []tokenizerCase + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, c := range cases { + var want []filterTerm + for _, term := range c.Terms { + want = append(want, filterTerm{term.Field, term.Value, term.Negated}) + } + if got := parseFilter(c.Input).terms; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("parseFilter(%q) = %v, want %v", c.Input, got, want) + } + } +} + +func TestTextTermsSearchIDTextChoiceAndRationale(t *testing.T) { + e := Entry{ID: "d9", Kind: "decision", Question: "The cache lives in Redis.", Choice: "Redis", Rationale: "Latency.", Author: "alice"} + for query, want := range map[string]bool{ + "d9": true, "cache": true, "REDIS": true, "latency": true, "alice": false, "decision": false, + "cache redis": true, "cache postgres": false, "-postgres": true, "-redis": false, + `"in redis"`: true, `"redis in"`: false, + } { + if got := parseFilter(query).textMatches(e); got != want { + t.Errorf("textMatches(%q) = %v, want %v", query, got, want) + } + } + if !parseFilter("kind:decision").hasFields() || parseFilter(`"kind:decision"`).hasFields() { + t.Fatal("hasFields misread a field term or a quoted term") + } +} + +func TestLivePreviewAndsWordsAndHonoursNegation(t *testing.T) { + m := NewModel(testData()) + m, _ = updateModel(m, keyMsg('/')) + m = typeText(m, "o -root") + if !m.searching { + t.Fatal("typing closed the filter input") + } + if got := visibleIDs(m); got != "d3,d4" { + t.Fatalf("live preview rows = %q, want d3,d4", got) + } +} + +func TestEnterAppliesTextAndEmptyEnterClears(t *testing.T) { + m := applyFilter(NewModel(testData()), "other") + if m.searching || m.query != "other" || visibleIDs(m) != "d4" { + t.Fatalf("text filter: searching=%v query=%q rows=%q", m.searching, m.query, visibleIDs(m)) + } + m = applyFilter(m, "") + if m.query != "" || visibleIDs(m) != "d1,d2,d3,d4" { + t.Fatalf("empty enter did not clear: query=%q rows=%q", m.query, visibleIDs(m)) + } +} + +func TestEscRestoresTheRowsBeforeTheEdit(t *testing.T) { + m := applyFilter(NewModel(testData()), "other") + m, _ = updateModel(m, keyMsg('/')) + m = typeText(m, "zzz") + if got := visibleIDs(m); got != "" { + t.Fatalf("preview rows = %q, want none", got) + } + m, _ = updateModel(m, keyMsg(0x1b)) + if m.searching || m.query != "other" || visibleIDs(m) != "d4" { + t.Fatalf("esc: searching=%v query=%q rows=%q", m.searching, m.query, visibleIDs(m)) + } +} + +func TestTabIsIgnoredWhileEditing(t *testing.T) { + m := NewModel(testData()) + m, _ = updateModel(m, keyMsg('/')) + m, _ = updateModel(m, keyMsg('\t')) + if !m.searching || m.detailFocus { + t.Fatalf("tab while editing: searching=%v detailFocus=%v", m.searching, m.detailFocus) + } +} + +func TestSelectionStaysOnTheRecordAcrossAFilterChange(t *testing.T) { + m := NewModel(testData()) + for m.selectedID() != "d4" { + m, _ = updateModel(m, keyMsg('j')) + } + m = applyFilter(m, "o") + if m.selectedID() != "d4" || m.detailID != "d4" { + t.Fatalf("selection = %q after a filter that keeps d4", m.selectedID()) + } + m = applyFilter(m, "child") + if m.selectedID() != "d2" || m.selected != 0 { + t.Fatalf("selection = %q at %d, want the first row d2", m.selectedID(), m.selected) + } +} + +func TestFieldTermsWithoutAFilterCommandApplyOnlyTheText(t *testing.T) { + m := applyFilter(NewModel(testData()), "kind:decision other") + if got := visibleIDs(m); got != "d4" { + t.Fatalf("rows = %q, want the text term's d4", got) + } + if m.status != "field filters need docket" || !m.statusErr || m.query != "kind:decision other" { + t.Fatalf("status=%q err=%v query=%q", m.status, m.statusErr, m.query) + } +} + +func typeText(m model, text string) model { + for _, r := range text { + m, _ = updateModel(m, tea.KeyPressMsg(tea.Key{Code: r, Text: string(r)})) + } + return m +} + +func applyFilter(m model, query string) model { + m, _ = updateModel(m, keyMsg('/')) + m.searchInput.SetValue(query) + m, _ = updateModel(m, keyMsg('\r')) + return m +} + +func visibleIDs(m model) string { + ids := make([]string, 0, len(m.rows)) + for _, row := range m.visibleRows() { + ids = append(ids, row.id) + } + return strings.Join(ids, ",") +} + +func TestPayloadFilterAppliesAtStartup(t *testing.T) { + path := t.TempDir() + "/graph.json" + const payload = `{"version":2,"entries":[{"id":"d1","question":"Root"},{"id":"d2","question":"Child","supports":["d1"]},{"id":"d3","question":"Other"}],"filter":{"query":"kind:decision","ids":["d1","d3"]}}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + data, err := readData(path) + if err != nil { + t.Fatal(err) + } + m := NewModel(data) + if m.query != "kind:decision" || visibleIDs(m) != "d1,d3" || m.selectedID() != "d1" { + t.Fatalf("startup filter: query=%q rows=%q selected=%q", m.query, visibleIDs(m), m.selectedID()) + } +} + +func TestParseFilterCmd(t *testing.T) { + argv, err := parseFilterCmd(`["/usr/bin/python3","/x/bin/docket","_filter-ids","--"]`) + if err != nil || len(argv) != 4 || argv[3] != "--" { + t.Fatalf("argv=%q err=%v", argv, err) + } + if argv, err := parseFilterCmd(""); argv != nil || err != nil { + t.Fatalf("empty flag: argv=%q err=%v", argv, err) + } + for _, bad := range []string{"[]", "not json", `{"a":1}`, `[1,2]`} { + if _, err := parseFilterCmd(bad); err == nil { + t.Errorf("parseFilterCmd(%q) accepted a bad value", bad) + } + } +} + +func TestFieldQueryRunsTheCommandAndKeepsTheReturnedIDs(t *testing.T) { + argv, dir := writeFilterScript(t, `printf '%s\n' "$@" > "$(dirname "$0")/args" +printf 'd1\nd4\n' +`) + m := newModelWithFilter(testData(), false, argv) + m, cmd := submit(m, "kind:decision is:pinned") + if m.status != "filtering…" || m.statusErr { + t.Fatalf("status while filtering = %q", m.status) + } + m = runFilter(t, m, cmd) + if got := visibleIDs(m); got != "d1,d4" { + t.Fatalf("rows = %q, want d1,d4", got) + } + if m.status != "" || m.query != "kind:decision is:pinned" { + t.Fatalf("status=%q query=%q", m.status, m.query) + } + args, err := os.ReadFile(filepath.Join(dir, "args")) + if err != nil || string(args) != "--\nkind:decision is:pinned\n" { + t.Fatalf("command args = %q, err %v; want the query as one argument after --", args, err) + } +} + +func TestTextOnlyEnterRunsNoCommand(t *testing.T) { + argv, dir := writeFilterScript(t, `touch "$(dirname "$0")/ran" +`) + m := newModelWithFilter(testData(), false, argv) + m, cmd := submit(m, "other -root") + if cmd != nil { + t.Fatal("a text-only query returned a command") + } + if visibleIDs(m) != "d4" || m.status != "" { + t.Fatalf("rows=%q status=%q", visibleIDs(m), m.status) + } + if _, err := os.Stat(filepath.Join(dir, "ran")); err == nil { + t.Fatal("a text-only query ran the filter command") + } +} + +func TestFailingFilterCommandKeepsRowsAndShowsTheLastStderrLine(t *testing.T) { + argv, _ := writeFilterScript(t, `echo "Traceback (most recent call last):" >&2 +echo "docket: where: kind:nope: unknown kind nope; use claim, decision, question" >&2 +echo "" >&2 +exit 1 +`) + m := newModelWithFilter(testData(), false, argv) + m, _ = submit(m, "other") + m, cmd := submit(m, "kind:nope") + m = runFilter(t, m, cmd) + if visibleIDs(m) != "d4" || m.query != "other" { + t.Fatalf("a failed filter changed the view: rows=%q query=%q", visibleIDs(m), m.query) + } + if m.status != "docket: where: kind:nope: unknown kind nope; use claim, decision, question" || !m.statusErr { + t.Fatalf("status = %q, err %v", m.status, m.statusErr) + } +} + +func TestFailedCallbackDuringEditRestoresTheAppliedState(t *testing.T) { + argv, _ := writeFilterScript(t, `echo "docket: where: kind:nope: unknown kind nope; use claim, decision, question" >&2 +exit 1 +`) + m := newModelWithFilter(testData(), false, argv) + m = applyFilter(m, "other") + m, cmd := submit(m, "kind:nope root") + m, _ = updateModel(m, keyMsg('/')) + m = runFilter(t, m, cmd) + m, _ = updateModel(m, keyMsg(0x1b)) + if visibleIDs(m) != "d4" || m.query != "other" { + t.Fatalf("after esc: rows=%q query=%q, want d4 and other", visibleIDs(m), m.query) + } +} + +func TestFailedSecondQueryRestoresLastAppliedNotAsPreview(t *testing.T) { + argv, _ := writeFilterScript(t, `case "$2" in +*first*) printf 'd1\n' ;; +*) exit 1 ;; +esac +`) + m := newModelWithFilter(testData(), false, argv) + m, first := submit(m, "kind:decision first") + m, second := submit(m, "kind:decision second") + m = runFilter(t, m, second) + m = runFilter(t, m, first) + if visibleIDs(m) != "d1,d2,d3,d4" || m.query != "" { + t.Fatalf("after failing B: rows=%q query=%q, want the untouched initial state", visibleIDs(m), m.query) + } +} + +func TestCallbackTimeoutKeepsRowsAndReportsIt(t *testing.T) { + argv, _ := writeFilterScript(t, `sleep 2 +printf 'd1\n' +`) + old := filterTimeout + filterTimeout = 50 * time.Millisecond + defer func() { filterTimeout = old }() + m := newModelWithFilter(testData(), false, argv) + m = applyFilter(m, "other") + m, cmd := submit(m, "kind:decision second") + m = runFilter(t, m, cmd) + if visibleIDs(m) != "d4" || m.query != "other" { + t.Fatalf("rows=%q query=%q, want the last applied filter kept", visibleIDs(m), m.query) + } + if !m.statusErr || !strings.Contains(m.status, "timed out") { + t.Fatalf("status = %q err=%v, want a timeout error", m.status, m.statusErr) + } +} + +func TestStaleFilterResultIsDropped(t *testing.T) { + argv, _ := writeFilterScript(t, `case "$2" in +*first*) printf 'd1\n' ;; +*) printf 'd4\n' ;; +esac +`) + m := newModelWithFilter(testData(), false, argv) + m, first := submit(m, "kind:decision first") + m, second := submit(m, "kind:decision second") + m = runFilter(t, m, second) + m = runFilter(t, m, first) + if visibleIDs(m) != "d4" || m.query != "kind:decision second" { + t.Fatalf("a stale result replaced the latest: rows=%q query=%q", visibleIDs(m), m.query) + } +} + +// writeFilterScript stands in for `docket _filter-ids`, so these tests never +// need Python. +func writeFilterScript(t *testing.T, body string) ([]string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the filter command fixture is a POSIX shell script") + } + dir := t.TempDir() + script := filepath.Join(dir, "filter.sh") + if err := os.WriteFile(script, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return []string{"/bin/sh", script, "--"}, dir +} + +func submit(m model, query string) (model, tea.Cmd) { + m, _ = updateModel(m, keyMsg('/')) + m.searchInput.SetValue(query) + return updateModel(m, keyMsg('\r')) +} + +func runFilter(t *testing.T, m model, cmd tea.Cmd) model { + t.Helper() + if cmd == nil { + t.Fatal("a field query returned no command") + } + m, _ = updateModel(m, cmd()) + return m +} + +func TestBottomPaneAtEightyAndFiftyEightColumns(t *testing.T) { + m := NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 80, Height: 24}) + assertPane(t, m, "filter: none", "4/4 match · sort ledger asc", "/ filter s sort tab detail ? help q quit") + m, _ = updateModel(m, keyMsg(' ')) + assertPane(t, m, "filter: none", "4/4 match · 2 shown · sort ledger asc", "/ filter s sort tab detail ? help q quit") + m, _ = updateModel(m, keyMsg(' ')) + m = applyFilter(m, "other") + assertPane(t, m, "filter: other", "1/4 match · sort ledger asc", "/ filter s sort tab detail ? help q quit") + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 58, Height: 24}) + assertPane(t, m, "filter: other", "1/4 match · sort ledger asc", "? help q quit") +} + +func TestHeightsFourSixAndEight(t *testing.T) { + for _, height := range []int{4, 6} { + m := NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 80, Height: height}) + lines := viewLines(m) + if len(lines) != height || !strings.Contains(lines[0], "d1") { + t.Fatalf("height %d: %d lines, first %q", height, len(lines), lines[0]) + } + if got := lines[height-1]; got != "? help 4/4 match · sort ledger asc" { + t.Fatalf("height %d: pane line = %q", height, got) + } + m, _ = updateModel(m, keyMsg('/')) + m = typeText(m, "ot") + if got := viewLines(m)[height-1]; !strings.HasPrefix(got, "filter: ot") { + t.Fatalf("height %d: editing pane line = %q", height, got) + } + } + m := NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 80, Height: 8}) + if lines := viewLines(m); len(lines) != 8 || lines[5] != "filter: none" || !strings.Contains(lines[0], "d1") { + t.Fatalf("height 8: %q", lines) + } + + m = NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 80, Height: 3}) + m, _ = updateModel(m, keyMsg('/')) + m = typeText(m, "ot") + if got := viewLines(m)[1]; !strings.HasPrefix(got, "filter: ot") { + t.Fatalf("height 3: editing second line = %q", got) + } +} + +func TestHelpStaysVisibleAtHeightFiveWithALongStatus(t *testing.T) { + argv, _ := writeFilterScript(t, `echo "docket: where: kind:nope: unknown kind nope; use claim, decision, question" >&2 +exit 1 +`) + m := newModelWithFilter(testData(), false, argv) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 40, Height: 5}) + m, cmd := submit(m, "kind:nope") + m = runFilter(t, m, cmd) + if got := viewLines(m)[4]; !strings.Contains(got, "? help") { + t.Fatalf("status line = %q, want ? help visible at width 40", got) + } +} + +func TestTreeKeepsARowAtEveryHeightFromFour(t *testing.T) { + for _, width := range []int{40, 80} { + for height := 4; height <= 30; height++ { + m := NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: width, Height: height}) + lines := viewLines(m) + if len(lines) != height || !strings.Contains(lines[0], "d1") { + t.Fatalf("%dx%d: %d lines, first %q", width, height, len(lines), lines[0]) + } + } + } +} + +func TestHelpOverlayOpensFromDetailFocusAndAnyKeyClosesIt(t *testing.T) { + m := NewModel(testData()) + m, _ = updateModel(m, tea.WindowSizeMsg{Width: 80, Height: 24}) + m, _ = updateModel(m, keyMsg('\t')) + m, _ = updateModel(m, keyMsg('?')) + if !m.helpOpen { + t.Fatal("? in detail focus did not open the help overlay") + } + view := strings.Join(viewLines(m), "\n") + for _, want := range []string{"move", "fold", "detail", "sort", "filter", "scope:PATH", "scope:DIR/"} { + if !strings.Contains(view, want) { + t.Errorf("help overlay missing %q:\n%s", want, view) + } + } + before := m.detail.YOffset() + m, _ = updateModel(m, keyMsg('j')) + if m.helpOpen || !m.detailFocus || m.detail.YOffset() != before { + t.Fatalf("j did not only close the overlay: open=%v focus=%v y=%d", m.helpOpen, m.detailFocus, m.detail.YOffset()) + } + m, _ = updateModel(m, keyMsg('?')) + m, cmd := updateModel(m, keyMsg('q')) + if m.helpOpen || cmd != nil { + t.Fatal("q closed the overlay and also quit") + } +} + +func assertPane(t *testing.T, m model, want ...string) { + t.Helper() + lines := viewLines(m) + got := lines[len(lines)-len(want):] + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("bottom pane = %q, want %q", got, want) + } +} + +func viewLines(m model) []string { + lines := strings.Split(ansi.Strip(m.View().Content), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return lines +} + func keyMsg(k rune) tea.KeyPressMsg { return tea.KeyPressMsg(tea.Key{Code: k}) } func updateModel(m model, msg tea.Msg) (model, tea.Cmd) { diff --git a/graph/model.go b/graph/model.go index f38e14a..bf140c2 100644 --- a/graph/model.go +++ b/graph/model.go @@ -17,8 +17,16 @@ import ( ) type GraphData struct { - Version int `json:"version"` - Entries []Entry `json:"entries"` + Version int `json:"version"` + Entries []Entry `json:"entries"` + Filter *GraphFilter `json:"filter"` +} + +// GraphFilter is the --where query the CLI already applied, and the ids it +// kept. +type GraphFilter struct { + Query string `json:"query"` + IDs []string `json:"ids"` } type Entry struct { @@ -82,6 +90,17 @@ type model struct { searchInput textinput.Model searching bool query string + shown map[string]bool + prevShown map[string]bool + prevQuery string + appliedShown map[string]bool + appliedQuery string + status string + statusErr bool + filterCmd []string + filterSeq int + pendingQuery string + helpOpen bool detail viewport.Model detailFocus bool detailID string @@ -94,7 +113,8 @@ var ( keyUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) keyDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) keyCollapse = key.NewBinding(key.WithKeys("space", "enter"), key.WithHelp("space", "collapse")) - keySearch = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")) + keySearch = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) + keyHelp = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")) keyQuit = key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")) keyTab = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "detail")) keyPageUp = key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "detail up")) @@ -105,19 +125,17 @@ var ( keyBottom = key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last row")) keySort = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort field")) keyReverse = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reverse sort")) - footerUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move")) - footerDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move")) - footerCollapse = key.NewBinding(key.WithKeys("space", "enter"), key.WithHelp("space", "fold")) - footerTab = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "view")) - footerSearch = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "find")) + keyApply = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply")) + keyCancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) + footerSearch = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")) + footerSort = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")) + footerTab = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "detail")) + footerHelp = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")) footerQuit = key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")) footerPageUp = key.NewBinding(key.WithKeys("pgup", "ctrl+u"), key.WithHelp("pgup", "up")) footerPageDown = key.NewBinding(key.WithKeys("pgdown", "ctrl+d"), key.WithHelp("pgdn", "down")) footerLeft = key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("h/←", "left")) footerRight = key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("l/→", "right")) - footerJump = key.NewBinding(key.WithKeys("g", "G"), key.WithHelp("gg/G", "ends")) - footerSort = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")) - footerReverse = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reverse")) ) type sortField int @@ -144,7 +162,16 @@ func NewModel(data GraphData) model { } func newModel(data GraphData, pretty bool) model { + return newModelWithFilter(data, pretty, nil) +} + +// newModelWithFilter takes the argv of the command that answers a query with +// field terms; nil leaves field terms unanswered. +func newModelWithFilter(data GraphData, pretty bool, filterCmd []string) model { m := model{data: data, entries: make(map[string]Entry), collapsed: make(map[string]bool), width: 80, height: 24, pretty: pretty} + if len(filterCmd) > 0 { + m.filterCmd = filterCmd + } for _, entry := range data.Entries { if entry.ID == "" || m.entries[entry.ID].ID != "" { continue @@ -154,10 +181,19 @@ func newModel(data GraphData, pretty bool) model { } m.rows = buildRows(m.order, m.entries) m.projectedChildren = projectedChildren(m.rows) + if data.Filter != nil { + m.query = sanitize(data.Filter.Query) + m.shown = make(map[string]bool, len(data.Filter.IDs)) + for _, id := range data.Filter.IDs { + m.shown[id] = true + } + } + m.appliedShown, m.appliedQuery = m.shown, m.query m.searchInput = textinput.New() - m.searchInput.Prompt = "/ " - m.searchInput.Placeholder = "find an entry" + m.searchInput.Prompt = "filter: " + m.searchInput.Placeholder = "words and field:value terms" m.help = help.New() + m.help.ShortSeparator = " " m.detail = viewport.New(viewport.WithWidth(35), viewport.WithHeight(18)) m.refreshDetail() return m @@ -319,7 +355,7 @@ func (m model) visibleRows() []graphRow { } hiddenDepth = -1 } - if m.query != "" && !m.matches(row.id) { + if m.shown != nil && !m.shown[row.id] { continue } rows = append(rows, row) @@ -330,32 +366,6 @@ func (m model) visibleRows() []graphRow { return rows } -func (m model) matches(id string) bool { - e := m.entries[id] - needle := strings.ToLower(m.query) - values := []string{e.ID, e.Kind, e.State, e.RecordedState, e.Question, e.Answer, e.Choice, e.Cost, e.Rationale, e.Revisit, e.Author, e.DecidedBy, e.TS, e.Branch, e.Session, e.RetiredBy} - values = append(values, e.Scope...) - values = append(values, e.Alternatives...) - values = append(values, e.Supports...) - values = append(values, e.DependsOn...) - values = append(values, e.Answers...) - values = append(values, e.Supersedes...) - values = append(values, e.ResolvedBy...) - values = append(values, e.BlockedBy...) - for _, set := range e.Sets { - values = append(values, set...) - } - for _, evidence := range e.Evidence { - values = append(values, evidence.Ref, evidence.CheckedAt, evidence.Commit) - } - for _, value := range values { - if strings.Contains(strings.ToLower(sanitize(value)), needle) { - return true - } - } - return false -} - func (m *model) refreshDetail() { id := m.selectedID() if id == "" { @@ -601,33 +611,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = max(1, msg.Width), max(1, msg.Height) m.resize() return m, nil + case filterResultMsg: + return m.applyFilterResult(msg), nil case tea.KeyPressMsg: + if m.helpOpen { + m.helpOpen = false + return m, nil + } if !m.searching && key.Matches(msg, keyQuit) { return m, tea.Quit } if m.searching { - if key.Matches(msg, keyTab) { - m.searching = false - m.searchInput.Blur() + switch { + case key.Matches(msg, keyTab): return m, nil - } - if key.Matches(msg, key.NewBinding(key.WithKeys("enter"))) { - m.query = strings.TrimSpace(sanitize(m.searchInput.Value())) - m.searching = false - m.searchInput.Blur() - m.selected = 0 - m.refreshDetail() - return m, nil - } - if key.Matches(msg, key.NewBinding(key.WithKeys("esc"))) { + case key.Matches(msg, keyApply): + next, cmd := m.submitFilter() + return next, cmd + case key.Matches(msg, keyCancel): + id := m.selectedID() m.searching = false m.searchInput.Blur() + m.shown, m.query = m.prevShown, m.prevQuery + m.reselect(id) return m, nil } var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) + m.previewFilter() return m, cmd } + if key.Matches(msg, keyHelp) { + m.helpOpen = true + m.pendingG = false + return m, nil + } if m.pendingG { m.pendingG = false if key.Matches(msg, keyTop) && !m.detailFocus { @@ -640,6 +658,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if key.Matches(msg, keySearch) { m.searching = true + m.prevShown, m.prevQuery = m.appliedShown, m.appliedQuery m.searchInput.SetValue(m.query) return m, m.searchInput.Focus() } @@ -723,13 +742,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *model) resize() { - m.searchInput.SetWidth(max(1, m.width-4)) + m.searchInput.SetWidth(max(1, m.width-lipgloss.Width(m.searchInput.Prompt)-1)) + body := bodyHeightFor(m.height) if m.width >= 70 { m.detail.SetWidth(max(1, m.width-m.width*46/100-3)) - m.detail.SetHeight(max(1, m.height-5)) + m.detail.SetHeight(max(1, body-1)) } else { m.detail.SetWidth(max(1, m.width-2)) - m.detail.SetHeight(max(1, m.height/2-2)) + m.detail.SetHeight(max(1, body-overviewHeightFor(body)-1)) } m.refreshDetail() } @@ -744,6 +764,9 @@ func (m model) View() tea.View { if m.height < 1 { m.height = 1 } + if m.helpOpen { + return altView(m.helpView()) + } if m.height <= 3 { line := "GRAPH" if id := m.selectedID(); id != "" { @@ -751,11 +774,13 @@ func (m model) View() tea.View { } lines := []string{fitLine(line, m.width)} if m.height >= 2 { - lines = append(lines, fitLine(m.footer(), m.width)) + second := m.footer() + if m.searching { + second = m.filterLine() + } + lines = append(lines, fitLine(second, m.width)) } - view := tea.NewView(strings.Join(lines, "\n")) - view.AltScreen = true - return view + return altView(strings.Join(lines, "\n")) } leftWidth := m.width wide := m.width >= 70 @@ -763,7 +788,7 @@ func (m model) View() tea.View { leftWidth = max(28, m.width*46/100) } bodyHeight := bodyHeightFor(m.height) - overviewHeight := min(bodyHeight-1, max(3, bodyHeight/2)) + overviewHeight := overviewHeightFor(bodyHeight) rows := m.visibleRows() titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7A86E")) selectedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F4D7A1")).Background(lipgloss.Color("#3A2F26")) @@ -772,19 +797,10 @@ func (m model) View() tea.View { kindStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#B9A58C")) recordTitleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E2D5C4")) idWidth := overviewIDWidth(rows, leftWidth) - leftLines := []string{m.paint(titleStyle, fitLine("LEDGER GRAPH", leftWidth))} - if m.searching { - searchView := m.searchInput.View() - if !m.pretty { - searchView = ansi.Strip(searchView) - } - leftLines = append(leftLines, fitLine(searchView, leftWidth)) - } else if m.query != "" { - leftLines = append(leftLines, m.paint(mutedStyle, fitLine("/ "+m.query, leftWidth))) - } - rowCapacity := max(1, bodyHeight-len(leftLines)) + leftLines := make([]string, 0, bodyHeight) + rowCapacity := bodyHeight if !wide { - rowCapacity = max(1, overviewHeight-len(leftLines)) + rowCapacity = overviewHeight } rowStart := 0 if m.selected >= rowCapacity { @@ -905,10 +921,8 @@ func (m model) View() tea.View { } out[i] = fitLine(leftLines[i], leftWidth) + "│" + fitLine(r, rightWidth) } - out = append(out, m.paint(mutedStyle, fitLine(m.footer(), m.width))) - view := tea.NewView(strings.Join(out, "\n")) - view.AltScreen = true - return view + out = append(out, m.bottomPane()...) + return altView(strings.Join(out, "\n")) } // At narrow widths the panes stack and each receives a real share of the // screen. The detail viewport remains scrollable with tab + j/k. @@ -938,14 +952,10 @@ func (m model) View() tea.View { for len(stack) < bodyHeight { stack = append(stack, fitLine("", m.width)) } - stack = append(stack, m.paint(mutedStyle, fitLine(m.footer(), m.width))) - view := tea.NewView(strings.Join(stack, "\n")) - view.AltScreen = true - return view + stack = append(stack, m.bottomPane()...) + return altView(strings.Join(stack, "\n")) } -func bodyHeightFor(height int) int { return max(1, height-3) } - func overviewIDWidth(rows []graphRow, available int) int { width := 2 for _, row := range rows { @@ -958,32 +968,6 @@ func (m model) hasChildren(id string) bool { return m.projectedChildren[id] } -func (m model) footer() string { - if m.searching { - return "enter apply esc cancel" - } - if m.width < 70 { - if m.detailFocus { - return "pgup/pgdn scroll h/l side tab tree q quit" - } - // Below 70 columns fitLine truncates the tail, so the sort state leads: - // a dropped key hint is recoverable at a wider size, the state is not. - return m.sortStatus() + " j/k move gg/G ends space fold tab detail / find q quit" - } - bindings := []key.Binding{footerUp, footerDown, footerJump, footerCollapse, footerTab, footerSearch, footerSort, footerReverse, footerQuit} - status := " " + m.sortStatus() - if m.detailFocus { - bindings = []key.Binding{footerPageUp, footerPageDown, footerLeft, footerRight, footerTab, footerQuit} - status = "" - } - m.help.SetWidth(max(1, m.width-lipgloss.Width(status))) - result := m.help.ShortHelpView(bindings) + status - if !m.pretty { - return ansi.Strip(result) - } - return result -} - func (m model) sortStatus() string { direction := "asc" if m.sortDesc { diff --git a/graph/pane.go b/graph/pane.go new file mode 100644 index 0000000..edfe06f --- /dev/null +++ b/graph/pane.go @@ -0,0 +1,161 @@ +package main + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// bodyHeightFor is the height left to the tree and the detail pane above the +// bottom pane: three pane lines from 8 rows up, one from 4 to 7. At 3 rows or +// fewer View draws its own fallback and uses no body. +func bodyHeightFor(height int) int { + switch { + case height >= 8: + return height - 3 + case height >= 4: + return height - 1 + default: + return 0 + } +} + +// overviewHeightFor is the tree's share of a stacked narrow body. The detail +// header takes one row below it, and the tree keeps at least one. +func overviewHeightFor(body int) int { + return max(1, min(body-1, max(3, body/2))) +} + +func altView(content string) tea.View { + view := tea.NewView(content) + view.AltScreen = true + return view +} + +func (m model) bottomPane() []string { + if m.height < 8 { + if m.searching { + return []string{fitLine(m.filterLine(), m.width)} + } + return []string{fitLine("? help "+m.statusLine(), m.width)} + } + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#7F8C98")) + return []string{ + fitLine(m.filterLine(), m.width), + fitLine(m.statusLine(), m.width), + m.paint(muted, fitLine(m.footer(), m.width)), + } +} + +func (m model) filterLine() string { + if m.searching { + view := m.searchInput.View() + if !m.pretty { + view = ansi.Strip(view) + } + return view + } + if m.query == "" { + return "filter: none" + } + return "filter: " + m.query +} + +func (m model) statusLine() string { + total := len(m.order) + matched := total + if m.shown != nil { + matched = 0 + for _, id := range m.order { + if m.shown[id] { + matched++ + } + } + } + parts := []string{fmt.Sprintf("%d/%d match", matched, total)} + if visible := len(m.visibleRows()); visible < matched { + parts = append(parts, fmt.Sprintf("%d shown", visible)) + } + parts = append(parts, m.sortStatus()) + if m.status != "" { + status := sanitize(m.status) + if m.statusErr { + status = m.paint(m.stateStyle("blocked"), status) + } + parts = append(parts, status) + } + return strings.Join(parts, " · ") +} + +func (m model) footer() string { + if m.searching { + return "enter apply esc cancel" + } + if m.width < 60 { + return "? help q quit" + } + bindings := []key.Binding{footerSearch, footerSort, footerTab, footerHelp, footerQuit} + if m.detailFocus { + bindings = []key.Binding{footerPageUp, footerPageDown, footerLeft, footerRight, footerTab, footerHelp, footerQuit} + } + m.help.SetWidth(m.width) + result := m.help.ShortHelpView(bindings) + if !m.pretty { + return ansi.Strip(result) + } + return result +} + +// helpHeading titles a column of the help overlay. Its key never reaches +// Update, which matches only the bindings in model.go. +func helpHeading(name string) key.Binding { + return key.NewBinding(key.WithKeys(name), key.WithHelp(name, "")) +} + +var helpRows = [][][]key.Binding{ + { + {helpHeading("move"), keyUp, keyDown, keyTop, keyBottom}, + {helpHeading("fold"), keyCollapse}, + {helpHeading("sort"), keySort, keyReverse}, + }, + { + {helpHeading("detail"), keyTab, keyPageUp, keyPageDown, keyLeft, keyRight}, + {helpHeading("filter"), keySearch, keyApply, keyCancel}, + }, +} + +var filterTermsHelp = []string{ + "filter terms words AND; repeats of one field OR; is: terms AND", + " word id, text, choice, or rationale contains it", + " -term excludes; \"a phrase\" is always text", + " kind:K state:S is:pinned|corrected|retired|blocked", + " author:A branch:B after:YYYY-MM-DD before:YYYY-MM-DD", + " scope:PATH records whose scope governs the file PATH", + " scope:DIR/ records scoped to anything under DIR/", +} + +func (m model) helpView() string { + h := help.New() + lines := []string{"keys (any key closes this)"} + for _, row := range helpRows { + lines = append(lines, "") + lines = append(lines, strings.Split(h.FullHelpView(row), "\n")...) + } + lines = append(lines, "") + lines = append(lines, filterTermsHelp...) + for i, line := range lines { + if !m.pretty { + line = ansi.Strip(line) + } + lines[i] = fitLine(line, m.width) + } + if len(lines) > m.height { + lines = lines[:m.height] + } + return strings.Join(lines, "\n") +} diff --git a/graph/testdata/tokenizer_cases.json b/graph/testdata/tokenizer_cases.json new file mode 100644 index 0000000..7b10cda --- /dev/null +++ b/graph/testdata/tokenizer_cases.json @@ -0,0 +1,71 @@ +[ + { + "input": "kind:decision -is:retired scope:docket/ledger.py cache", + "terms": [ + {"field": "kind", "value": "decision", "negated": false}, + {"field": "is", "value": "retired", "negated": true}, + {"field": "scope", "value": "docket/ledger.py", "negated": false}, + {"field": "", "value": "cache", "negated": false} + ] + }, + { + "input": "author:\"a teammate\"", + "terms": [ + {"field": "author", "value": "a teammate", "negated": false} + ] + }, + { + "input": "\"d12:\"", + "terms": [ + {"field": "", "value": "d12:", "negated": false} + ] + }, + { + "input": "\"https://x.test\"", + "terms": [ + {"field": "", "value": "https://x.test", "negated": false} + ] + }, + { + "input": "-\"two words\" Tail", + "terms": [ + {"field": "", "value": "two words", "negated": true}, + {"field": "", "value": "tail", "negated": false} + ] + }, + { + "input": "a\"b c\"d", + "terms": [ + {"field": "", "value": "ab cd", "negated": false} + ] + }, + { + "input": "x1:y", + "terms": [ + {"field": "", "value": "x1:y", "negated": false} + ] + }, + { + "input": "\"open", + "terms": [ + {"field": "", "value": "open", "negated": false} + ] + }, + { + "input": "- lone", + "terms": [ + {"field": "", "value": "-", "negated": false}, + {"field": "", "value": "lone", "negated": false} + ] + }, + { + "input": ":x", + "terms": [ + {"field": "", "value": ":x", "negated": false} + ] + }, + { + "input": "", + "terms": [] + } +] diff --git a/skills/docket/SKILL.md b/skills/docket/SKILL.md index d2d6d84..69d7402 100644 --- a/skills/docket/SKILL.md +++ b/skills/docket/SKILL.md @@ -159,6 +159,7 @@ docket context --query "cache" --file src/cache.py --max-chars 4000 docket context --all --max-chars 12000 docket list docket list --kind decision --state adopted --json +docket list --where 'kind:decision is:pinned scope:docket/ledger.py' docket show d2 --json docket show d2 --at d40 docket context --since d40 @@ -228,7 +229,13 @@ Work with the briefing as follows: A delegated agent runs step 3 for its own scope and reports the revision it used, so the delegating agent knows which briefing the work rests on. -`list` and `graph` accept `--kind` and `--state`. `show --json` exposes the +`list` and `graph` accept `--kind`, `--state`, and `--where QUERY`. A query +combines plain words with `kind:`, `state:`, `scope:PATH`, `is:pinned`, +`is:retired`, `author:`, and `after:YYYY-MM-DD` terms; see +[the query language](../../docs/commands.md#query-language). `scope:PATH` asks +which records govern that file, and `scope:DIR/` covers a directory. + +`show --json` exposes the original record with derived fields such as `recorded_state`, effective `state`, `retired_by`, `resolved_by`, `applicable`, and `blocked_by`. `graph` preserves the full support sets as well as the renderer's deduplicated support union. @@ -241,7 +248,7 @@ the full support sets as well as the renderer's deduplicated support union. | `dot` | stdout | `dot -Tsvg`, and a layout that holds up past a hundred nodes | | `csv` | `nodes.csv` and `edges.csv` in `--out DIR` | Gephi, or anything measuring the graph rather than drawing it | -`--kind`, `--state`, `--find`, `--superseded` and `--detail` narrow all three. +`--kind`, `--state`, `--find`, `--where`, `--superseded` and `--detail` narrow all three. Narrow before exporting: the whole ledger is a hairball in any of them. Evidence references are provenance supplied by the recorder. Docket does not diff --git a/tests/test_docket.py b/tests/test_docket.py index 0481809..204eeb2 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -15,11 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from docket import ROOT, ledger # noqa: E402 from docket import env as docket_env # noqa: E402 -from docket import ledger # noqa: E402 from docket.cli import autoscope as cli_autoscope # noqa: E402 from docket.cli import context_cmd as cli_context_cmd # noqa: E402 from docket.cli import graph as cli_graph # noqa: E402 +from docket.cli import main as cli_main # noqa: E402 from docket.cli import query as cli_query # noqa: E402 from docket.ledger import make_record # noqa: E402 @@ -678,6 +679,231 @@ def test_rebase_appends_a_renumbered_tail(self): self.assertIn("Their premise", after) +def _where_ledger(root): + """c1 and q2, then d3 retired by d4, which d5 retires in turn.""" + (root / ".git").mkdir() + path = root / ".docket" / "ledger.jsonl" + path.parent.mkdir() + records = [ + make_record( + "claim", "Writes are durable.", state="accepted", author="test", record_id="c1" + ), + make_record("question", "Where does the cache live", author="test", record_id="q2"), + make_record( + "decision", + "The cache lives in Redis.", + choice="Redis", + supports=[["c1"]], + pinned=True, + author="test", + record_id="d3", + ), + make_record( + "decision", + "The cache lives in memory.", + choice="memory", + supersedes=["d3"], + author="test", + record_id="d4", + ), + make_record( + "decision", + "The cache lives on disk.", + choice="disk", + supersedes=["d4"], + author="test", + record_id="d5", + ), + ] + path.write_text("\n".join(json.dumps(item) for item in records) + "\n") + return path + + +def _graph_args(**overrides): + values = { + "style": None, + "state": None, + "kind": None, + "find": None, + "plain": True, + "pretty": False, + "interactive": False, + "no_interactive": False, + "where": None, + "format": None, + "out": None, + "superseded": False, + "detail": 40, + "direction": "LR", + } + values.update(overrides) + return argparse.Namespace(**values) + + +class WhereCliTests(unittest.TestCase): + def list_ids(self, root, *flags): + result = run(root, "list", "--json", *flags) + self.assertEqual(result.returncode, 0, result.stderr) + return [item["id"] for item in json.loads(result.stdout)] + + def test_list_where_hides_retired_records_unless_asked(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + self.assertEqual(self.list_ids(root, "--where", "kind:decision"), ["d5"]) + self.assertEqual( + self.list_ids(root, "--where", "kind:decision", "--superseded"), ["d3", "d4", "d5"] + ) + self.assertEqual(self.list_ids(root, "--where", "is:pinned"), []) + self.assertEqual(self.list_ids(root, "--where", "is:pinned", "--superseded"), ["d3"]) + + def test_list_where_is_retired_pulls_retired_records_in(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + self.assertEqual(self.list_ids(root, "--where", "is:retired"), ["d3", "d4"]) + + def test_list_where_ands_with_kind(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + self.assertEqual(self.list_ids(root, "--where", "cache", "--kind", "question"), ["q2"]) + self.assertEqual(self.list_ids(root, "--where", "kind:decision", "--kind", "claim"), []) + + def test_list_where_takes_a_negated_query_after_an_equals_sign(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + self.assertEqual(self.list_ids(root, "--where=-kind:question"), ["c1", "d5"]) + + def test_list_where_refuses_an_invalid_query(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + result = run(root, "list", "--where", "colour:red") + self.assertEqual(result.returncode, 1) + self.assertIn("unknown field colour", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_graph_format_with_is_retired_keeps_retired_records(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + result = run( + root, "graph", "--format", "mermaid", "--no-interactive", "--where", "is:retired" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("d4 -- retires --> d3", result.stdout) + self.assertIn("classDef retired", result.stdout) + self.assertNotIn("d5", result.stdout) + + def test_static_graph_honours_where(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + result = run(root, "graph", "--no-interactive", "--plain", "--where", "kind:claim") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Writes are durable.", result.stdout) + self.assertNotIn("on disk", result.stdout) + + def test_the_no_viewer_fallback_honours_where(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = _where_ledger(root) + out, err = io.StringIO(), io.StringIO() + with ( + mock.patch.object(docket_env, "ledger_path", lambda: path), + mock.patch.object(cli_graph, "_graph_is_tty", lambda: True), + mock.patch.object(cli_graph, "_graph_viewer_path", lambda: root / "missing"), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + code = cli_graph.cmd_graph(_graph_args(plain=False, where="kind:claim")) + self.assertEqual(code, 0) + self.assertIn("Writes are durable.", out.getvalue()) + self.assertNotIn("on disk", out.getvalue()) + self.assertIn("build", err.getvalue().lower()) + + def test_an_invalid_where_exits_before_the_viewer_starts(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = _where_ledger(root) + viewer = root / "viewer" + viewer.write_text("viewer") + calls = [] + err = io.StringIO() + with ( + mock.patch.object(docket_env, "ledger_path", lambda: path), + mock.patch.object(cli_graph, "_graph_is_tty", lambda: True), + mock.patch.object(cli_graph, "_graph_viewer_path", lambda: viewer), + mock.patch.object(cli_graph.subprocess, "run", lambda *a, **k: calls.append(a)), + contextlib.redirect_stderr(err), + ): + code = cli_main(["graph", "--where", "kind:nope"]) + self.assertEqual(code, 1) + self.assertEqual(calls, []) + self.assertIn("unknown kind nope", err.getvalue()) + + def test_the_viewer_payload_carries_the_filter_and_the_callback(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = _where_ledger(root) + viewer = root / "viewer" + viewer.write_text("viewer") + seen = {} + + def fake_run(argv, **kwargs): + seen["argv"] = argv + seen["env"] = kwargs.get("env") or {} + seen["payload"] = json.loads(Path(argv[2]).read_text()) + return subprocess.CompletedProcess(argv, 0) + + with ( + mock.patch.object(docket_env, "ledger_path", lambda: path), + mock.patch.object(cli_graph, "_graph_is_tty", lambda: True), + mock.patch.object(cli_graph, "_graph_viewer_path", lambda: viewer), + mock.patch.object(cli_graph.subprocess, "run", fake_run), + ): + self.assertEqual( + cli_graph.cmd_graph(_graph_args(plain=False, where="kind:claim")), 0 + ) + payload = seen["payload"] + self.assertEqual(payload["version"], 2) + self.assertEqual( + [item["id"] for item in payload["entries"]], ["c1", "q2", "d3", "d4", "d5"] + ) + self.assertEqual(payload["filter"], {"query": "kind:claim", "ids": ["c1"]}) + self.assertEqual( + json.loads(seen["env"]["DOCKET_GRAPH_FILTER_CMD"]), + [sys.executable, str(ROOT / "bin" / "docket"), "_filter-ids", "--"], + ) + self.assertNotIn("--filter-cmd", seen["argv"]) + + self.assertEqual(cli_graph.cmd_graph(_graph_args(plain=False)), 0) + self.assertNotIn("filter", seen["payload"]) + self.assertIn("DOCKET_GRAPH_FILTER_CMD", seen["env"]) + + def test_filter_ids_takes_a_negated_query_after_the_separator(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + result = run(root, "_filter-ids", "--", "-kind:question") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.split(), ["c1", "d3", "d4", "d5"]) + result = run(root, "_filter-ids", "--", "kind:decision is:pinned") + self.assertEqual(result.stdout.split(), ["d3"]) + + def test_filter_ids_reports_a_bad_query_on_stderr(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _where_ledger(root) + result = run(root, "_filter-ids", "--", "kind:nope") + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertIn("unknown kind nope", result.stderr.strip().splitlines()[-1]) + self.assertNotIn("Traceback", result.stderr) + + class FeatureSkillTests(unittest.TestCase): def test_the_feature_skill_exists_and_declares_its_trigger(self): path = Path(__file__).parent.parent / "skills" / "docket-feature" / "SKILL.md" diff --git a/tests/test_where.py b/tests/test_where.py new file mode 100644 index 0000000..d0f8603 --- /dev/null +++ b/tests/test_where.py @@ -0,0 +1,254 @@ +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from docket import ledger, where + +# graph/testdata/tokenizer_cases.json holds this table. The viewer previews +# text terms with its own tokenizer, so both must split every case the same +# way; graph/main_test.go TestFilterTokenizerMatchesPython reads the same file. +_CASES_PATH = Path(__file__).parent.parent / "graph" / "testdata" / "tokenizer_cases.json" +with _CASES_PATH.open(encoding="utf-8") as _f: + SHARED_CASES = [ + (case["input"], [(t["field"], t["value"], t["negated"]) for t in case["terms"]]) + for case in json.load(_f) + ] + + +def entry(**fields): + record = { + "id": "c1", + "kind": "claim", + "text": "", + "state": "accepted", + "recorded_state": "accepted", + "ts": "2026-09-10T12:00:00+00:00", + "author": "alice", + "branch": "main", + "scope": [], + "rationale": "", + "pinned": False, + "retired_by": "", + } + record.update(fields) + return record + + +def hit(query, **fields): + return where.parse(query).matches(entry(**fields)) + + +class TokenizerTests(unittest.TestCase): + def test_cases_shared_with_the_viewer(self): + for query, expected in SHARED_CASES: + terms = [(t.field, t.value, t.negated) for t in where.parse(query).terms] + self.assertEqual(terms, expected, query) + + +class TextTermTests(unittest.TestCase): + def test_text_searches_id_text_choice_and_rationale(self): + self.assertTrue(hit("c1")) + self.assertTrue(hit("cache", text="The cache lives in Redis.")) + self.assertTrue(hit("redis", kind="decision", choice="Redis")) + self.assertTrue(hit("latency", rationale="Latency matters.")) + self.assertFalse(hit("alice")) + self.assertFalse(hit("claim")) + + def test_words_and_each_other(self): + self.assertTrue(hit("cache redis", text="cache in redis")) + self.assertFalse(hit("cache postgres", text="cache in redis")) + + def test_a_negated_word_excludes(self): + self.assertFalse(hit("-redis", text="cache in redis")) + self.assertTrue(hit("-postgres", text="cache in redis")) + + def test_a_quoted_phrase_keeps_its_space(self): + self.assertTrue(hit('"in redis"', text="cache in redis")) + self.assertFalse(hit('"redis in"', text="cache in redis")) + + def test_a_colon_inside_a_quoted_term_stays_text(self): + query = where.parse('"d12:"') + self.assertFalse(query.has_fields) + self.assertTrue(query.matches(entry(text="see d12: the cache"))) + self.assertFalse(query.matches(entry(text="see d12 the cache"))) + + def test_case_is_ignored(self): + self.assertTrue(hit("REDIS", text="redis")) + self.assertTrue(hit("KIND:Decision", kind="decision")) + + def test_an_empty_query_matches_everything(self): + self.assertTrue(hit("")) + self.assertTrue(hit(" ")) + + +class FieldTermTests(unittest.TestCase): + def test_kind(self): + self.assertTrue(hit("kind:claim")) + self.assertFalse(hit("kind:decision")) + + def test_repeats_of_one_field_or_each_other(self): + query = where.parse("kind:claim kind:decision") + self.assertTrue(query.matches(entry(kind="claim"))) + self.assertTrue(query.matches(entry(kind="decision"))) + self.assertFalse(query.matches(entry(kind="question"))) + + def test_different_fields_and_each_other(self): + self.assertTrue(hit("kind:decision author:bob", kind="decision", author="bob")) + self.assertFalse(hit("kind:decision author:bob", kind="decision", author="alice")) + + def test_state_is_the_effective_state(self): + answered = {"kind": "question", "state": "resolved", "recorded_state": "open"} + self.assertTrue(hit("state:resolved", **answered)) + self.assertFalse(hit("state:open", **answered)) + + def test_is_values(self): + self.assertTrue(hit("is:pinned", pinned=True)) + self.assertFalse(hit("is:pinned")) + self.assertTrue(hit("is:corrected", corrections=["c1.1"])) + self.assertFalse(hit("is:corrected")) + self.assertTrue(hit("is:retired", retired_by="c2")) + self.assertFalse(hit("is:retired")) + + def test_is_terms_and_each_other(self): + self.assertFalse(hit("is:pinned is:corrected", pinned=True)) + self.assertTrue(hit("is:pinned is:corrected", pinned=True, corrections=["c1.1"])) + + def test_author_and_branch_match_a_substring(self): + self.assertTrue(hit('author:"a teammate"', author="A Teammate (bot)")) + self.assertFalse(hit('author:"a teammate"', author="teammate")) + self.assertTrue(hit("branch:feat", branch="feature/x")) + self.assertFalse(hit("branch:feat", branch="main")) + + def test_a_negated_field_term_ands_alone(self): + self.assertFalse(hit("-kind:question", kind="question")) + self.assertTrue(hit("-kind:question", kind="claim")) + self.assertFalse(hit("kind:claim kind:decision -is:pinned", pinned=True)) + self.assertTrue(hit("kind:claim kind:decision -is:pinned")) + + +class DateTermTests(unittest.TestCase): + def test_after_includes_the_day_and_before_excludes_it(self): + self.assertTrue(hit("after:2026-09-10")) + self.assertFalse(hit("after:2026-09-11")) + self.assertTrue(hit("before:2026-09-11")) + self.assertFalse(hit("before:2026-09-10")) + + def test_the_date_is_taken_in_utc(self): + # 23:30 at UTC-2 is 01:30 on the next day in UTC. + self.assertTrue(hit("after:2026-09-02", ts="2026-09-01T23:30:00-02:00")) + self.assertFalse(hit("before:2026-09-02", ts="2026-09-01T23:30:00-02:00")) + + def test_an_empty_or_unparsable_ts_never_matches(self): + for ts in ("", "yesterday"): + self.assertFalse(hit("after:2000-01-01", ts=ts), ts) + self.assertFalse(hit("before:2999-01-01", ts=ts), ts) + + def test_negation_inverts_the_date_term(self): + self.assertTrue(hit("-after:2000-01-01", ts="yesterday")) + + +class ScopeTermTests(unittest.TestCase): + def test_a_path_matches_exact_glob_and_directory_scopes(self): + for scope in (["docket/ledger.py"], ["docket/*.py"], ["docket/"]): + self.assertTrue(hit("scope:docket/ledger.py", scope=scope), scope) + self.assertFalse(hit("scope:docket/ledger.py", scope=["graph/main.go"])) + + def test_a_top_level_file_matches_an_equal_entry(self): + self.assertTrue(hit("scope:README.md", scope=["README.md"])) + self.assertFalse(hit("scope:README.md", scope=["docs/README.md"])) + + def test_a_directory_matches_entries_under_it_and_entries_that_govern_it(self): + for scope in (["graph/main.go"], ["graph/**"], ["graph"], ["graph/"]): + self.assertTrue(hit("scope:graph/", scope=scope), scope) + self.assertTrue(hit("scope:docket/cli/", scope=["docket/"])) + self.assertFalse(hit("scope:graph/", scope=["docs/graph.md"])) + + def test_scope_paths_ignore_case(self): + self.assertTrue(hit("scope:Graph/Main.go", scope=["graph/main.go"])) + + +class BlockedTests(unittest.TestCase): + def test_blocked_ignores_retired_and_revoked_decisions(self): + def decision(ident, text, **kwargs): + return ledger.make_record( + "decision", text, choice=f"option {ident}", author="t", record_id=ident, **kwargs + ) + + entries = ledger.project( + [ + ledger.make_record("claim", "Unassessed premise.", author="t", record_id="c1"), + decision("d2", "Blocked choice.", depends_on=["c1"]), + decision("d3", "Revoked choice.", state="revoked"), + decision("d4", "Old choice."), + decision("d5", "New choice.", supersedes=["d4"]), + ] + ) + by_id = {item["id"]: item for item in entries} + # The fixture holds the trap: both carry their own id in blocked_by. + self.assertEqual(by_id["d3"]["blocked_by"], ["d3"]) + self.assertEqual(by_id["d4"]["blocked_by"], ["d4"]) + query = where.parse("is:blocked") + self.assertEqual([item["id"] for item in entries if query.matches(item)], ["d2"]) + + +class ErrorTests(unittest.TestCase): + def assertRefused(self, query, *fragments): + with self.assertRaises(where.WhereError) as caught: + where.parse(query) + for fragment in fragments: + self.assertIn(fragment, str(caught.exception)) + + def test_where_error_is_a_value_error(self): + self.assertTrue(issubclass(where.WhereError, ValueError)) + + def test_an_unknown_field_names_the_term_and_lists_the_fields(self): + self.assertRefused( + "colour:red", + "colour:red", + "after, author, before, branch, is, kind, scope, state", + "quote", + ) + + def test_an_unknown_kind(self): + self.assertRefused("kind:note", "kind:note", "claim, decision, question") + + def test_an_unknown_state(self): + self.assertRefused("state:done", "state:done", "adopted", "unassessed") + + def test_retired_and_blocked_are_not_states(self): + self.assertRefused("state:retired", "use is:retired") + self.assertRefused("state:blocked", "use is:blocked") + + def test_an_unknown_is_value(self): + self.assertRefused("is:open", "is:open", "blocked, corrected, pinned, retired") + + def test_a_malformed_date(self): + for query in ("after:2026-9-1", "before:2026-13-01", "after:20260901", "after:yesterday"): + self.assertRefused(query, query, "YYYY-MM-DD") + + def test_a_field_with_no_value(self): + self.assertRefused("kind:", "kind:", "needs a value") + + def test_a_negated_term_keeps_its_sign_in_the_message(self): + self.assertRefused("-kind:note", "-kind:note") + + +class FlagTests(unittest.TestCase): + def test_wants_retired(self): + self.assertTrue(where.parse("is:retired").wants_retired) + self.assertTrue(where.parse("kind:decision is:retired").wants_retired) + self.assertFalse(where.parse("-is:retired").wants_retired) + self.assertFalse(where.parse("retired").wants_retired) + self.assertFalse(where.parse("").wants_retired) + + def test_has_fields(self): + self.assertTrue(where.parse("kind:claim").has_fields) + self.assertTrue(where.parse("-author:bob").has_fields) + self.assertFalse(where.parse("cache -redis").has_fields) + self.assertFalse(where.parse('"kind:claim"').has_fields) + + +if __name__ == "__main__": + unittest.main()