# Specification: Resource types (generalising the first token beyond files)

## Context

`bioassert` validates pipeline outputs with declarative assertions of the shape
`<file> <metric> <comparator> <value>`. The first token is always a filesystem path, and every metric
(`file.*`, `tsv.*`, `bam.*`, `fasta.*`) operates on that path. This is the right default for a tool that checks
pipeline outputs, but it is narrower than the syntax needs to be.

Two related wants motivate widening it:

- **Non-file resources.** A check like `http://example.com http.status_code eq 200` reads a URL, not a file. The
  resource being asserted on is a network endpoint, yet the assertion shape fits unchanged.
- **Literal / inline resources for dynamic generation.** When an assertions file is generated by a pipeline, the
  author may want to embed a value and assert on it, for example `'x' text.length gt 5`, or use such a value as a
  guard so a downstream check runs only under some condition.

The central observation is that this needs almost no new grammar. The first token is already lexed as an opaque
whitespace-delimited or quoted run, and dispatch is already driven entirely by the metric, not the resource. This
spec generalises the first token from "a file path" to "a resource locator" whose meaning is decided by the
metric namespace, and adds one new resource family (`text`) as the worked example. `http`, structured-data
resources (`json`) and a variables layer are described but explicitly deferred.

The intended outcome: the same `<resource> <metric> <comparator> <value>` line works whether the resource is a
file, an inline literal or (later) a URL, with no per-type syntax and no second prefix on the locator.

## Constraints and key facts

- **The grammar already accepts non-path first tokens.** In `src/engine/assertions.pest`,
  `file = { unquoted | single_quoted | double_quoted }` and `unquoted = @{ (!WHITESPACE ~ ANY)+ }`. A URL such as
  `http://example.com` contains no whitespace so it already matches `unquoted`, and an inline literal `'x'`
  already matches `single_quoted`. No structural grammar change is required to *parse* these. The only grammar
  change is cosmetic: renaming the `file` rule to `resource` so the grammar reads honestly. The same rename
  applies to `condition`, which reuses `file`.
- **Dispatch is metric-driven, not resource-driven.** `src/engine/executor.rs::dispatch` selects an executor
  purely by `*Executor::try_parse(metric)`. The locator is carried along untouched. This means the metric's first
  segment (`file`, `bam`, `fasta`, `tsv`, and now `text`) is *already* the resource-type discriminator. There is
  no need to add a `file:` or `url:` prefix on the locator: that information lives in the metric namespace.
- **The locator is currently typed as a path too early.** `src/core/assertion_request.rs` defines
  `AssertionRequest { file: PathBuf, comparator: Comparator, expected: String }`, and
  `src/engine/executor.rs::run_metric` builds it with `file: PathBuf::from(file)`. A `PathBuf` is the wrong type
  for an inline literal or a URL. This is the one real refactor: the request must carry the locator as a raw
  string and let each executor interpret it (a path for `file.*`, the literal itself for `text.*`, a URL for a
  future `http.*`).
- **The AST already stores the locator as a `String`.** `src/engine/assertion.rs` has `Assertion { file: String,
  .. }` and `Condition { file: String, .. }`. The narrowing to `PathBuf` happens only inside `run_metric`. So the
  refactor is contained to the request type and the executors, not the parser or AST.
- **Executor interpretation is per-family today.** Each `*Executor` already owns how it reads its resource
  (`src/file/*/functions.rs` opens files, `src/bam/functions.rs` parses with `noodles`). Adding a `text` family
  that reads its locator verbatim fits this pattern with no new abstraction.
- **Quoting and `strip_quotes` already exist.** `core::strip_quotes` unwraps single- or double-quoted strings and
  is already used by executors for the expected value. An inline literal locator (`'x'`) is lexed *with* its
  quotes, so the locator needs the same unwrapping. See Design decisions for where to do it.
- **String comparison is already first class.** `core::comparisons` provides `Comparator::compare`,
  `compare_string` and `string_matcher`, so a `text.value` metric can reuse the existing string comparators
  (`eq`, `ne`, `starts`, `ends`, `contains`, `matches`) with no new comparison code.

## Design decisions

- **The metric namespace is the resource-type discriminator. No second prefix on the locator.** `file.size`
  means "treat the locator as a path", `text.length` means "treat the locator as a literal", `http.status_code`
  (deferred) means "treat the locator as a URL". Adding `file:` / `url:` / `str:` prefixes on the locator would
  duplicate information the metric already carries and would force the author to keep two things in sync. The
  locator stays a single opaque token.
- **Infer the resource type from the metric, never by sniffing the locator.** Deciding "starts with `http://` so
  it is a URL" is brittle (relative URLs, paths that look like URLs, future schemes) and it splits the type
  decision across two tokens. The metric already names the type unambiguously, exactly as `bam.header.rg.count`
  today says "open this as a BAM". A consequence is that a mismatched pair (for example `file.size` on a URL
  locator) is simply attempted and errors, which is acceptable. See "Coherence checking" under Deferred for an
  optional improvement.
- **One unified line shape, not per-type syntax.** `<resource> <metric> <comparator> <value>` already generalises.
  Every new resource family reuses it. There is no `http { ... }` block or alternative grammar.
- **The locator becomes a raw `String` in the request, interpreted lazily by the executor.** An eagerly resolved
  `Resource` enum (`File(PathBuf) | Url(Url) | Text(String)`) would need to know the type before dispatch, but
  the type comes *from* the metric, which is a chicken-and-egg ordering. Keeping the locator a `String` and letting
  each executor interpret it preserves the existing metric-first dispatch and is the smaller change. The field is
  renamed `file -> locator` on `AssertionRequest` to match.
- **Unwrap the locator's quotes once, centrally.** Today the expected value is unwrapped per-executor with
  `strip_quotes`. The locator should be unwrapped once in `run_metric` before the request is built, so that
  `file.*` executors transparently gain support for quoted paths containing spaces (`'my output.tsv'`) at the same
  time. This is additive: existing unquoted paths are unchanged, since `strip_quotes` is a no-op on an unquoted
  string. Doing it centrally avoids every new family having to remember to strip its locator.
- **`text` is the first new family because it is the cheapest and unblocks dynamic generation.** It has no I/O, no
  new dependency and no non-determinism. It reuses the existing string comparators directly. It is the worked
  example that proves the generalisation end to end. `http` is deferred because network I/O changes the tool's
  character (see Deferred).
- **A `text` resource for guards is a building block, not the headline feature.** A guard that compares two
  literals the author wrote at generation time (`if 'x' text.value eq 'y'`) decides nothing the generator could
  not have decided itself. The durable use is a guard that reads a *runtime* property (a sidecar manifest or, later,
  the environment). `text` is shipped because it is trivial and composes with guards, not because literal-vs-literal
  guards are themselves valuable. This is called out in the docs so authors reach for a manifest resource when the
  decision input is only known at runtime.

## Syntax

### Grammar changes (`src/engine/assertions.pest`)

The change is a rename plus one reorder. Replace the `file` rule name with `resource`, update its two uses (the
`assertion` rule and `condition`), and put the quoted alternatives first:

```pest
resource = { single_quoted | double_quoted | unquoted }

condition = { resource ~ metric ~ comparator ~ value }

assertion = {
    resource ~ metric ~ comparator ~ value ~ (guard_keyword ~ condition)? ~ EOI
}
```

`unquoted`, `single_quoted`, `double_quoted`, `metric`, `comparator` and `value` are unchanged. Because `unquoted`
accepts any non-whitespace run, `http://example.com` and `s3://bucket/key` and inline tokens all parse with no
further work. The `metric` rule already accepts any dotted identifier chain, so `text.value`, `text.length` and
`http.status_code` need no grammar addition.

The reorder matters: PEG ordered choice commits to the first alternative that matches, and a sequence does not
backtrack into an earlier choice when a later element fails. With `unquoted` first, a quoted locator containing a
space (`'my output.tsv'`) would be truncated to `'my` by `unquoted`, then the rest would fail to parse as a metric
and the whole assertion would error. Trying `single_quoted` / `double_quoted` first matches such a locator whole;
for an unquoted path or URL the quoted alternatives fail fast and `unquoted` matches as before. Quote-free inline
literals (`'abc'`) parse either way, since `unquoted` would capture the quotes too and `strip_quotes` removes them.

The rename and reorder do not change what previously parsed (existing fixtures use unquoted first tokens), so parser
tests
and snapshots are unaffected in behaviour. The parser reads the assertion's inner pairs positionally and does not
match on `Rule::file` by name, so the rule rename needs no parser change.

## Semantics

- **The locator is interpreted by the matched executor.** After dispatch picks an executor by metric, that
  executor decides what the locator means: a `file.*` executor does `Path::new(&request.locator)`, a `text.*`
  executor uses the locator string verbatim, a future `http.*` executor parses it as a URL.
- **Quotes are stripped once, before the request is built.** `run_metric` applies `core::strip_quotes` to the
  locator. A quoted inline literal `'x'` becomes `x`; an unquoted path is unchanged. Executors therefore receive a
  clean locator and never see surrounding quotes.
- **`text.value`** returns the locator string itself as a `Value::String`, compared with the existing string
  comparators. `'abc' text.value eq abc` passes; `'abc' text.value starts a` passes; `'abc' text.value matches '^a.c$'`
  passes.
- **`text.length`** returns the locator's length (Unicode scalar count) as an integer `Value`, compared with the
  numeric comparators. `'abc' text.length eq 3` passes; `'abc' text.length gt 5` fails.
- **`text` metrics never error on the resource.** Unlike `file.*`, a `text` resource is always present (it is the
  literal), so `text.*` produces PASS or FAIL but not the "could not open" ERROR. This makes `text` a safe guard
  input, in the same way `file.exists` is, though as noted its guard value is limited to generation-time decisions.
- **Mismatched metric and resource error rather than misbehave.** `http://example.com file.size gt 0` selects the
  `file.size` executor, which tries to stat a path literally named `http://example.com`, fails to open it and
  reports ERROR. This is the existing behaviour for a missing file and needs no special handling.

### Message formats

Unchanged. The existing `Expected <resource> <metric> <comparator> <expected>, got <actual>` format already reads
correctly for any resource, since it interpolates the locator string. The only visible difference is that the first
field may now be a literal or URL rather than a path. No format code changes.

## Examples

```
# Inline literal resource (the text family)
'abc'        text.length gt 2                 # PASS (length 3)
'abc'        text.length gt 5                 # FAIL
'abc'        text.value  eq abc               # PASS
'abc'        text.value  starts a             # PASS
'NC_000001'  text.value  matches '^NC_'       # PASS (quoted regex, dots/colon safe)

# text as a guard input (generation-time decision; see Design decisions for the caveat)
report.tsv   tsv.line.count gt 0  if 'tumor' text.value eq tumor    # runs the check
report.tsv   tsv.line.count gt 0  if 'normal' text.value eq tumor   # SKIP (guard not satisfied)

# Files are unchanged (file.size needs a byte unit, e.g. 0B)
tests/data/example.tsv   file.size gt 0B      # PASS
'my output.tsv'          file.size gt 0B      # PASS (quoted path with a space, now unwrapped centrally)

# Deferred: URL resource, shown for shape only, not implemented by this spec
# http://example.com     http.status_code eq 200
```

A value containing dots, dashes, colons or spaces must be quoted, exactly as documented today for expected values.

## Implementation

This spec implements the generalisation plus the `text` family. `http`, `json` and variables are deferred.

### 1. Grammar (`src/engine/assertions.pest`)

Rename the `file` rule to `resource` and update its two uses (`assertion`, `condition`) as shown in Syntax. No
other rule changes.

### 2. Request type (`src/core/assertion_request.rs`)

Rename the locator field and change its type from `PathBuf` to `String`:

```rust
pub struct AssertionRequest {
    pub locator: String,
    pub comparator: Comparator,
    pub expected: String,
}
```

File executors that today read `request.file` (a `PathBuf`) now read `request.locator` (a `String`) and construct
the path themselves with `Path::new(&request.locator)` before calling their `functions.rs` helper. The helpers in
`src/file/*/functions.rs`, `src/delimited/functions.rs`, `src/bam/functions.rs` and `src/fasta/functions.rs` keep
their `&Path` signatures unchanged; only the executor's call site changes from passing `&request.file` to passing
`Path::new(&request.locator)`. This keeps the I/O code and its tests untouched.

### 3. Dispatch and quote stripping (`src/engine/executor.rs`)

`run_metric` strips the locator's quotes once and builds the new request:

```rust
fn run_metric(resource: &str, metric: &str, comparator: &str, expected: &str)
    -> Result<(bool, Value, Comparator), BioAssertError>
{
    let request = AssertionRequest {
        locator: crate::core::strip_quotes(resource).to_string(),
        comparator: comparator.parse()?,
        expected: expected.to_string(),
    };
    let (success, actual) = dispatch(metric, &request)?;
    Ok((success, actual, request.comparator))
}
```

Add the `text` executors to the `dispatch` chain (after the existing families):

```rust
if let Some(e) = TextValueExecutor::try_parse(metric) { return run(e, request); }
if let Some(e) = TextLengthExecutor::try_parse(metric) { return run(e, request); }
```

The variable names in `evaluate` change from `file` to `resource`/`locator` for clarity, but the control flow
(guard handling, message formatting) is unchanged. The message format strings already interpolate the locator
string and need no edit.

### 4. New `text` module (`src/text/`)

Mirror the `src/file/` layout: a `text` module with one submodule per metric, each split into `executor.rs`
(parse and dispatch) and `functions.rs` (the work, plus unit tests).

```
src/text/
  mod.rs              // pub use TextValueExecutor, TextLengthExecutor
  value/
    mod.rs
    executor.rs       // try_parse: metric == "text.value"
    functions.rs
  length/
    mod.rs
    executor.rs       // try_parse: metric == "text.length"
    functions.rs
```

`TextValueExecutor::execute` returns the locator as a string `Value` and compares it with the string comparators
(reuse `request.comparator.string_matcher` / `compare_string`, the same path `delimited::column_all` uses, so a
`matches` regex compiles once). `TextLengthExecutor::execute` returns `request.locator.chars().count()` as an
integer `Value` parsed against the numeric comparators (`Value::from_integer` for the expected). Neither touches
the filesystem, so neither can produce a `FileError`.

Register the module in `src/lib.rs` (`pub mod text;`) alongside the existing families.

### 5. Docs (`CLAUDE.md`)

- Reframe the opening from "asserting properties of files" to "asserting properties of resources", with files as
  the primary resource and `text` as an inline resource.
- Add a `src/text/` bullet to the Architecture list following the `src/fasta/` entry.
- Note that the first token is a *resource locator* interpreted by the metric namespace, and that no per-resource
  prefix is needed because the metric's first segment selects the resource type.
- Document the `text.value` and `text.length` metrics and the rule that an inline literal is single- or
  double-quoted, unwrapped by the central `strip_quotes`.
- State the guard caveat: a `text` guard decides only on values known at generation time; for runtime decisions use
  a file or (deferred) manifest resource.

## Deferred

These are described so the design leaves room for them, but they are out of scope for this spec.

- **`http` (and other network) resources.** `http://... http.status_code eq 200`, `http.header.<name>`,
  `http.body.length`. Deferred because network I/O changes the tool's character: it introduces timeouts, retries,
  authentication, offline-CI behaviour, caching and non-determinism, none of which the current pure, local, fast
  model has to reason about. When added, network resources should be a distinct tier with their own timeout and
  retry policy and a way to run offline, not slotted in beside `file.size` as an equal. The grammar already parses
  the URL locator, so this is purely an executor-family and policy question.
- **Structured-data resources (`json`, `yaml`).** `manifest.json json.sample_type eq tumor`. This is the durable
  answer to "insert metadata and decide": the guard reads a runtime manifest rather than an inlined literal. It
  needs a path-addressing scheme inside the metric (for example `json.<dotted.path>`), which the existing `metric`
  grammar already accommodates, plus a JSON dependency.
- **Variables / parameters.** `${SAMPLE_TYPE}` substitution or a `--define KEY=VALUE` flag with a `var.*` resource.
  This is the cleanest fit for "decide based on environment metadata" but it is a substitution layer, not a resource
  type, and is orthogonal to this spec. A pre-processing step (envsubst-style) outside `bioassert` achieves the
  same thing today and keeps the tool pure.
- **Coherence checking (metric vs resource).** Optionally, validate that a metric is applied to a compatible
  locator and produce a clear error ("`file.size` cannot be applied to a URL") rather than a confusing
  "could not open" failure. This requires each family to declare the resource shape it expects. Worthwhile once
  more than one non-file family exists; unnecessary while `text` is the only addition.

## Test plan

Following the conventions in `src/` (per-metric unit tests in `functions.rs`) and `tests/` (binary-driven
integration tests with `insta` snapshots).

### Unit tests

- **`src/text/value/functions.rs`**: `eq`, `ne`, `starts`, `ends`, `contains` and `matches` against a known
  literal; a `matches` regex with dots and anchors; a non-matching case returns `success == false`.
- **`src/text/length/functions.rs`**: length of an ASCII literal; length counted in Unicode scalars for a
  multibyte literal; `gt` / `lt` / `eq` numeric comparisons; a non-numeric expected value errors.
- **`src/core/assertion_request.rs` / `src/engine/executor.rs`**: `run_metric` strips quotes from a quoted locator
  (`'x'` becomes `x`) and leaves an unquoted path unchanged; a quoted path containing a space resolves to the file.
- **Regression**: the existing `file.*`, `tsv.*`, `bam.*` and `fasta.*` unit tests pass unchanged after the
  `PathBuf -> String` request refactor (the executor call sites now build the `Path` locally).

### Integration tests and fixtures

| File | Kind | Tracked? | Contents |
|---|---|---|---|
| `tests/data/resource_types.txt` | fixture | yes | A batch mixing `text.value` and `text.length` PASS lines, a quoted-path `file.size` line, and a `text` guard producing a SKIP. All lines pass or skip so the run exits 0. |
| `tests/resource_subcommand_test.rs` | integration test | yes | Run-snapshot plus focused `assert` cases for FAIL and the mismatched-metric ERROR path. |
| `tests/snapshots/resource_subcommand_test__run_resource_stdout.snap` | snapshot | yes | Captured stdout, generated with `cargo insta review` then committed. |

Focused `assert`-subcommand cases:

- `'abc' text.length gt 2`: exit 0, stdout contains `PASS.`
- `'abc' text.length gt 5`: exit 1, stdout contains `FAIL.`
- `'abc' text.value matches '^a'`: exit 0, `PASS.`
- mismatched metric on a URL-looking locator (`http://example.com file.size gt 0B`): exit 2, `ERROR.`

Existing snapshots are unaffected because no current fixture uses the `text` family, and the grammar rename does not
change what parses.

## Critical files

- `src/engine/assertions.pest` — rename `file` rule to `resource`, reorder to quoted-first, update its two uses.
- `src/engine/parser.rs` — no change needed; the parser reads inner pairs positionally and never matches `Rule::file`.
- `src/core/assertion_request.rs` — `file: PathBuf` becomes `locator: String`, plus a `path()` helper that reads the
  locator as a `&Path` for the file-backed families.
- `src/engine/executor.rs` — central `strip_quotes` of the locator in `run_metric`, register `text` executors in
  `dispatch`, rename the `run_metric` `file` param to `resource`.
- `src/file/*/executor.rs`, `src/delimited/*/executor.rs`, `src/bam/*/executor.rs`, `src/fasta/*/executor.rs` —
  call sites pass `request.path()` instead of `&request.file`. `src/file/exists/functions.rs` widened from `&PathBuf`
  to `&Path` to match.
- `src/text/` — new module (`value`, `length`) following the `src/file/` shape.
- `src/lib.rs` — `pub mod text;`.
- `tests/resource_subcommand_test.rs`, `tests/data/resource_types.txt`, `tests/snapshots/` — new.
- `CLAUDE.md` — reframe files to resources, document the `text` family and the locator/metric-namespace rule.

## Verification

1. `cargo build --release` — confirms the grammar rename compiles and the request-type change is threaded through
   every executor call site.
2. `cargo test` — runs the new `text` unit tests, the locator quote-stripping tests, the integration tests and the
   full existing suite (proving the `PathBuf -> String` refactor is behaviour-preserving for files).
3. `cargo insta review` (or `INSTA_UPDATE=always cargo test`) — accept the new resource-types snapshot.
4. Manual end-to-end:
   ```bash
   cargo run -- assert "'abc' text.length gt 2"                        # PASS, exit 0
   cargo run -- assert "'abc' text.length gt 5"                        # FAIL, exit 1
   cargo run -- assert "'NC_000001' text.value matches '^NC_'"         # PASS, exit 0
   cargo run -- assert "tests/data/example.tsv file.size gt 0B"        # PASS (files unchanged)
   cargo run -- assert "http://example.com file.size gt 0B"            # ERROR, exit 2 (mismatched metric)
   cargo run -- run tests/data/resource_types.txt                      # batch report
   ```
5. Confirm a run whose assertions all pass or skip exits 0, and that the existing `run`/`bam`/`fasta` snapshots are
   unchanged.
